diff --git a/README.md b/README.md
index ab7210d..5fcdc8e 100644
--- a/README.md
+++ b/README.md
@@ -151,7 +151,7 @@ docs/ Settings reference, and the design specs behind the work
## Tests
-172 of them, under `tests/`. Run the lot, or a subset by pattern:
+173 of them, under `tests/`. Run the lot, or a subset by pattern:
```sh
panama test # everything
diff --git a/config/dot/quickshell/modules/settings/PrivacyLiveTile.qml b/config/dot/quickshell/modules/settings/PrivacyLiveTile.qml
new file mode 100644
index 0000000..35a4f2a
--- /dev/null
+++ b/config/dot/quickshell/modules/settings/PrivacyLiveTile.qml
@@ -0,0 +1,81 @@
+// One tile of the camera/microphone/screen strip: whether the thing is being
+// used right this second, and by what.
+//
+// PrivacyLiveTile {
+// glyph: "\u{F0100}"
+// label: "Camera"
+// active: PrivacyState.cameraActive
+// app: PrivacyState.cameraApp
+// }
+//
+// Deliberately still. An indicator that pulses or fades is reporting the same
+// fact over and over at sixty frames a second, and this one sits on a settings
+// page that may be open for a long time -- the border and the warn-toned line
+// carry the whole of the difference, and they carry it without repainting.
+
+import QtQuick
+import qs.config
+
+Rectangle {
+ id: root
+
+ property string glyph: ""
+ property string label: ""
+ property bool active: false
+ // The application name PipeWire reported, which is often empty even while
+ // a stream is plainly running.
+ property string app: ""
+
+ // Not `state`: Item already owns that name.
+ readonly property string useLine: root.active
+ ? "In use by " + (root.app !== "" ? root.app : "an application")
+ : "Idle"
+
+ implicitHeight: 56
+ radius: Theme.cardRadius
+ color: Theme.alpha(Theme.bgDark, 0.55)
+ border.width: 1
+ border.color: root.active
+ ? Theme.alpha(Theme.warn, 0.45)
+ : Theme.alpha(Theme.fg, 0.08)
+
+ Text {
+ id: icon
+
+ anchors.left: parent.left
+ anchors.leftMargin: 12
+ anchors.verticalCenter: parent.verticalCenter
+ text: root.glyph
+ color: root.active ? Theme.warn : Theme.fgDim
+ font.family: Theme.fontMono
+ font.pixelSize: 17
+ }
+
+ Column {
+ anchors.left: icon.right
+ anchors.leftMargin: 10
+ anchors.right: parent.right
+ anchors.rightMargin: 12
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 2
+
+ Text {
+ width: parent.width
+ text: root.label
+ color: Theme.fg
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.DemiBold
+ elide: Text.ElideRight
+ }
+
+ Text {
+ width: parent.width
+ text: root.useLine
+ color: root.active ? Theme.warn : Theme.fgDim
+ font.family: Theme.fontFamily
+ font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
+ elide: Text.ElideRight
+ }
+ }
+}
diff --git a/config/dot/quickshell/modules/settings/PrivacyPage.qml b/config/dot/quickshell/modules/settings/PrivacyPage.qml
index 30bb244..dda118f 100644
--- a/config/dot/quickshell/modules/settings/PrivacyPage.qml
+++ b/config/dot/quickshell/modules/settings/PrivacyPage.qml
@@ -1,15 +1,24 @@
-// Privacy & Security.
+// Privacy.
//
-// GNOME's Privacy panel covers screen lock, camera and microphone access, file
-// history, trash, and device security. Panama covers the parts it genuinely
-// owns and is explicit about the parts it does not.
+// Three questions, in the order anyone actually asks them: what can see or hear
+// me right now, what was allowed to before, and what does this machine remember
+// about what I did.
//
-// The file-history and trash settings are the notable omission, and the reason
-// is worth stating: those are GNOME preferences enforced by gsd-housekeeping,
-// which is not running in a Hyprland session. Offering switches for them would
-// store a preference, change nothing, and give no sign of it -- the exact
-// failure this codebase keeps designing against. So they are delegated by name
-// rather than reimplemented as controls that lie.
+// The first two used to be separate cards -- live PipeWire indicators in one,
+// portal permissions in another -- which split one subject in half and left the
+// screencast and remote-desktop tables, the two most consequential grants the
+// portal records, showing nowhere at all. They are one card now, live strip on
+// top and the tables under it.
+//
+// The third used to be a door: a card headed "Owned by Fedora" whose only row
+// opened GNOME's privacy panel, because file history and trash retention are
+// gsd-housekeeping preferences and gsd-housekeeping does not run here. That was
+// true about the SETTINGS and beside the point about the DATA. Clearing recent
+// files, thumbnails and the trash needs no daemon's cooperation, so Panama does
+// it, and the door is gone.
+//
+// What is deliberately not here: file-history and trash RETENTION switches,
+// which really would store a preference and change nothing.
import QtQuick
import qs.config
@@ -18,12 +27,70 @@ import qs.services
SettingsPage {
id: root
- title: "Privacy & Security"
+ objectName: "privacy"
+ title: "Privacy"
+ lede: "What can see you, what remembers you, and what this machine's hardware promises."
// The stored-secret list is collapsed until asked for, and one item at a
// time can be waiting on a confirmed Forget.
property bool showingSecrets: false
- property string confirmingPath: ""
+ // Which stored item's Forget is armed, and nothing else. It used to be read
+ // by the Copy button too, which turned Copy into "Cancel" on any row whose
+ // Forget was waiting -- so the press that meant "copy this password" meant
+ // "never mind" instead, and the button that did it was the one labelled
+ // Copy a moment earlier. Copy is only ever Copy now.
+ property string confirmingItem: ""
+
+ // "table/app" for the portal grant whose Revoke is armed, and the id of the
+ // trace row whose Clear is armed. One each -- arming a second disarms the
+ // first, which is what makes a misclick cheap.
+ property string confirmingRevoke: ""
+ property string confirmingTrace: ""
+
+ // The portal's tables, in the order they matter, with the words for each --
+ // what a row means, and what an empty section means. Which CONTROL a row
+ // gets is not decided here: the service answers that, from the helper's own
+ // table list, so a page cannot grow a switch for something the permission
+ // store refuses to write. Location is read-only and appears only when it
+ // has entries, because geoclue is not running on this machine.
+ readonly property var portalSections: [
+ {
+ table: "camera",
+ label: "Camera",
+ detail: "",
+ empty: "No app has asked for the camera. The portal records an answer the first time each one asks."
+ },
+ {
+ table: "microphone",
+ label: "Microphone",
+ detail: "",
+ empty: "No app has asked for the microphone. The portal records an answer the first time each one asks."
+ },
+ {
+ table: "screencast",
+ label: "Screen sharing",
+ detail: "Can capture the screen",
+ empty: "No app holds a screen-capture grant."
+ },
+ {
+ table: "remote-desktop",
+ label: "Remote desktop",
+ detail: "Can move the pointer and type",
+ empty: "No app holds a remote-control grant."
+ },
+ {
+ table: "location",
+ label: "Location",
+ detail: "Recorded by the portal",
+ empty: ""
+ }
+ ]
+
+ function askedCount(count: int): string {
+ if (count === 0)
+ return "";
+ return count + (count === 1 ? " app has asked" : " apps have asked");
+ }
// What a stored secret is FOR, from its attributes. Never its value.
function describe(item: var): string {
@@ -38,9 +105,18 @@ SettingsPage {
const schema = String(item?.schema ?? "");
return schema !== "" ? schema : "No further detail stored";
}
- lede: DeviceSecurity.scanned && DeviceSecurity.attentionCount === 0
- ? "Screen lock, device access, and a machine whose security settings all check out."
- : "Screen lock, which applications can see you, and how this machine is protected."
+
+ // The trash row is Storage's measurement, not a second one. Disks already
+ // walks ~/.local/share/Trash and already knows how to empty it through gio,
+ // and two implementations of "how big is the trash" would disagree the
+ // first time one of them was changed.
+ readonly property var trashCleanable: {
+ for (const item of Disks.cleanables) {
+ if (String(item.id ?? "") === "trash")
+ return item;
+ }
+ return null;
+ }
Component.onCompleted: {
if (!DeviceSecurity.scanned)
@@ -49,40 +125,249 @@ SettingsPage {
Keyring.refresh();
if (!Permissions.scanned)
Permissions.refresh();
+ // Two du walks over ~/.local/share/recently-used.xbel and
+ // ~/.cache/thumbnails. Cheap enough to do on open; the trash figure
+ // comes from Storage's own scan, which is not.
+ if (!Traces.measured)
+ Traces.measure();
+ if (!Disks.cleanablesMeasured)
+ Disks.measureCleanables();
}
+ // ── What can see and hear you ────────────────────────────────────────────
+
SettingsCard {
- title: "Screen lock"
- subtitle: "The same settings as Power & Lock, which is where the idle timings live."
+ title: "Camera, microphone & screen"
+ subtitle: Permissions.available
+ ? "What is in use now, and the answers applications gave the desktop portal when they asked."
+ : (Permissions.lastError || "The desktop portal's permission store is not running, so the grants below cannot be read.")
- SliderRow { setting: "lockMinutes"; zeroLabel: "Never" }
- ToggleRow { setting: "lockOnSleep"; divider: false }
+ Row {
+ width: parent.width
+ spacing: 10
+ bottomPadding: 12
+
+ PrivacyLiveTile {
+ width: (parent.width - 20) / 3
+ glyph: "\u{F0100}"
+ label: "Camera"
+ active: PrivacyState.cameraActive
+ app: PrivacyState.cameraApp
+ }
+
+ PrivacyLiveTile {
+ width: (parent.width - 20) / 3
+ glyph: "\u{F036C}"
+ label: "Microphone"
+ active: PrivacyState.microphoneActive
+ app: PrivacyState.microphoneApp
+ }
+
+ PrivacyLiveTile {
+ width: (parent.width - 20) / 3
+ glyph: "\u{F0379}"
+ label: "Screen sharing"
+ active: PrivacyState.screenSharingActive
+ app: PrivacyState.screenSharingApp
+ }
+ }
+
+ Repeater {
+ model: root.portalSections
+
+ delegate: Column {
+ id: section
+
+ required property var modelData
+
+ readonly property string table: String(section.modelData.table)
+ readonly property var rows: Permissions.rowsFor(section.table)
+ // The service decides, not the page: `simple` is a table the
+ // permission store will let something write, `revokeOnly` is one
+ // it will only let something delete. A table that is neither
+ // gets no buttons at all.
+ readonly property bool simple: Permissions.isSimple(section.table)
+ readonly property bool revokeOnly: Permissions.isRevokeOnly(section.table)
+
+ width: parent.width
+ // Location is the one section that disappears when empty: there
+ // is no geoclue on this machine, so an empty Location heading
+ // would be a category invented by the page.
+ visible: section.table !== "location" || section.rows.length > 0
+
+ SectionLabel {
+ text: String(section.modelData.label)
+ count: root.askedCount(section.rows.length)
+ }
+
+ TextRow {
+ width: section.width
+ visible: section.rows.length === 0
+ label: "Nothing has asked yet"
+ detail: String(section.modelData.empty)
+ divider: false
+ }
+
+ Repeater {
+ model: section.rows
+
+ delegate: SettingRow {
+ id: grantRow
+
+ required property var modelData
+ required property int index
+
+ readonly property string app: String(grantRow.modelData.app ?? "")
+ readonly property bool confirming:
+ root.confirmingRevoke === section.table + "/" + grantRow.app
+
+ width: section.width
+ label: grantRow.app
+ // `grants` is how many separate entries the store folded
+ // into this row -- screencast keeps one per remembered
+ // session, so an app that has shared four times is four
+ // entries and one question.
+ detail: grantRow.confirming
+ ? "The recorded answer goes away, and this app is asked again the next time it tries."
+ : (!section.simple && !section.revokeOnly
+ ? (grantRow.modelData.allowed === true ? "Allowed" : "Withheld")
+ : String(section.modelData.detail)
+ + (Number(grantRow.modelData.grants ?? 0) > 1
+ ? " · " + grantRow.modelData.grants + " remembered sessions"
+ : ""))
+ controlWidth: section.simple ? 175 : (section.revokeOnly ? 165 : 10)
+ divider: grantRow.index < section.rows.length - 1
+
+ Row {
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 8
+
+ // camera and microphone: forget the answer, or
+ // change it. Both are writes the store accepts.
+ SettingsButton {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: section.simple
+ text: "Ask again"
+ enabled: !Permissions.busy
+ onClicked: Permissions.revoke(section.table, grantRow.app)
+ }
+
+ SettingsToggle {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: section.simple
+ checked: grantRow.modelData.allowed === true
+ onToggled: value => Permissions.setPermission(
+ section.table, grantRow.app, value)
+ }
+
+ // screencast and remote-desktop: two presses, and
+ // no toggle at all. The value is a structured
+ // GVariant this page cannot compose, so revoking is
+ // the only thing it can honestly offer.
+ SettingsButton {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: section.revokeOnly && grantRow.confirming
+ text: "Revoke it"
+ tone: "danger"
+ enabled: !Permissions.busy
+ onClicked: {
+ root.confirmingRevoke = "";
+ Permissions.revoke(section.table, grantRow.app);
+ }
+ }
+
+ SettingsButton {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: section.revokeOnly
+ text: grantRow.confirming ? "Keep" : "Revoke…"
+ enabled: !Permissions.busy
+ onClicked: root.confirmingRevoke = grantRow.confirming
+ ? "" : section.table + "/" + grantRow.app
+ }
+ }
+ }
+ }
+
+ Item { width: 1; height: 4 }
+ }
+ }
+
+ // The limit, stated rather than left to be discovered. Saying "your
+ // camera is protected" when a system package can open /dev/video0
+ // would be a claim this page cannot back up.
+ TextRow {
+ label: "Only sandboxed apps are covered"
+ detail: "These are the answers Flatpak apps gave the permission portal. Programs installed as system packages are not sandboxed, never ask, and reach these devices directly."
+ divider: false
+ }
}
- // The login keyring, which nothing else surfaces.
+ // ── Background ───────────────────────────────────────────────────────────
+ // Its own card because it is a different question -- not who can watch you,
+ // but who keeps running when you thought you had closed them -- and because
+ // it is the longest table the portal keeps.
+
+ SettingsCard {
+ id: backgroundCard
+
+ readonly property var rows: Permissions.rowsFor("background")
+
+ visible: Permissions.available
+ title: "Run in the background"
+ subtitle: backgroundCard.rows.length === 0
+ ? "No app has asked to keep running with no window open."
+ : backgroundCard.rows.length + " app"
+ + (backgroundCard.rows.length === 1 ? " is" : "s are")
+ + " allowed to keep running with no window open."
+
+ Repeater {
+ model: backgroundCard.rows
+
+ delegate: SettingRow {
+ id: backgroundRow
+
+ required property var modelData
+ required property int index
+
+ width: parent.width
+ label: String(backgroundRow.modelData.app ?? "")
+ controlWidth: 70
+ divider: backgroundRow.index < backgroundCard.rows.length - 1
+
+ SettingsToggle {
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ checked: backgroundRow.modelData.allowed === true
+ onToggled: value => Permissions.setPermission(
+ "background", String(backgroundRow.modelData.app ?? ""), value)
+ }
+ }
+ }
+ }
+
+ // ── Saved passwords & secrets ────────────────────────────────────────────
//
- // It is unlocked at sign-in by PAM, so this card normally just confirms
+ // The keyring is unlocked at sign-in by PAM, so this normally just confirms
// that. It earns its place on the rare occasion it is not: a locked keyring
// breaks saved passwords everywhere at once, and does it without ever
// saying the word "keyring" -- you get a mail account that will not
// authenticate and a git push that cannot find its key.
+
SettingsCard {
visible: Keyring.scanned
- title: "Saved passwords"
+ title: "Saved passwords & secrets"
subtitle: !Keyring.available
? "No secret service is answering, so saved passwords are unavailable."
: Keyring.locked
- ? "The login keyring is locked. Saved passwords cannot be read until it is unlocked, and applications that need one will appear to fail for unrelated reasons."
- : "The login keyring is unlocked, as it is after every normal sign-in."
+ ? "The login keyring is locked. Applications that need a saved password will appear to fail for unrelated reasons until it is unlocked."
+ : "Values never leave the keyring except onto your clipboard, one at a time, when you ask."
- // Two rows rather than one with a conditional button: a locked keyring
- // needs an action, an unlocked one is a statement of fact, and ActionRow
- // and TextRow already say exactly those two things.
ActionRow {
visible: Keyring.available && Keyring.locked
label: "Login keyring"
detail: "Unlock to restore access to stored passwords and keys"
- action: Keyring.unlocking ? "Waiting…" : "Unlock"
+ action: Keyring.unlocking ? "Waiting…" : "Unlock…"
enabled: !Keyring.unlocking
divider: Keyring.replacementDaemon || Keyring.lastError !== ""
onTriggered: Keyring.unlock()
@@ -92,10 +377,10 @@ SettingsPage {
visible: !(Keyring.available && Keyring.locked)
label: "Login keyring"
detail: Keyring.available
- ? "Unlocked at sign-in by PAM, the same way GNOME does it"
+ ? "Unlocked with your password at sign-in, the same way GNOME does it"
: "No secret service is answering on this session"
value: Keyring.available ? "Unlocked" : "Unavailable"
- divider: Keyring.replacementDaemon || Keyring.lastError !== ""
+ divider: Keyring.available || Keyring.replacementDaemon || Keyring.lastError !== ""
}
// Only shown when it is true, because it is a diagnostic rather than a
@@ -106,30 +391,20 @@ SettingsPage {
label: "Keyring service"
detail: "The original keyring service was replaced during this session, usually after it crashed. Signing out and back in restores the one PAM unlocks."
value: "Replaced"
- divider: Keyring.lastError !== ""
+ divider: Keyring.available || Keyring.lastError !== ""
}
SettingRow {
visible: Keyring.lastError !== ""
label: "Keyring problem"
detail: Keyring.lastError
- divider: false
+ divider: Keyring.available
}
- }
-
-
- // ── What is actually stored ──────────────────────────────────────────────
- // Collapsed until asked. Opening the Privacy page should not enumerate
- // someone's saved passwords as a side effect, and the list is long enough
- // that it would bury every other setting on the page.
- SettingsCard {
- visible: Keyring.scanned && Keyring.available && !Keyring.locked
- title: "Stored secrets"
- subtitle: Keyring.listed
- ? "Passwords and tokens applications have saved. The values are never shown here."
- : "Passwords and tokens applications have saved, listed only when you ask."
+ // Collapsed until asked. Opening the Privacy page should not enumerate
+ // someone's saved passwords as a side effect.
ActionRow {
+ visible: Keyring.available && !Keyring.locked
label: "Saved items"
detail: Keyring.listed
? Keyring.storedCount + " stored across "
@@ -144,7 +419,7 @@ SettingsPage {
onTriggered: {
if (root.showingSecrets) {
root.showingSecrets = false;
- root.confirmingPath = "";
+ root.confirmingItem = "";
return;
}
root.showingSecrets = true;
@@ -153,14 +428,6 @@ SettingsPage {
}
}
- TextRow {
- visible: root.showingSecrets && Keyring.copiedPath !== ""
- label: "Copied to the clipboard"
- detail: "It clears itself in about a minute, unless you copy something else first."
- value: ""
- divider: true
- }
-
Repeater {
model: root.showingSecrets && Keyring.listed ? Keyring.collections : []
@@ -191,14 +458,15 @@ SettingsPage {
required property int index
readonly property string itemPath: String(secretRow.modelData.path ?? "")
- readonly property bool confirming: root.confirmingPath === secretRow.itemPath
+ readonly property bool confirming: root.confirmingItem === secretRow.itemPath
+ readonly property bool copied: Keyring.copiedPath === secretRow.itemPath
width: collectionBlock.width
label: String(secretRow.modelData.label ?? "")
// Attributes, never the value: what the secret is FOR is
// the part that identifies it.
detail: root.describe(secretRow.modelData)
- controlWidth: 200
+ controlWidth: 300
divider: secretRow.index < (collectionBlock.modelData.items ?? []).length - 1
Row {
@@ -206,31 +474,44 @@ SettingsPage {
anchors.verticalCenter: parent.verticalCenter
spacing: 8
+ // Said next to the row it happened on, and true:
+ // the helper leaves a watcher that clears the
+ // clipboard 45 seconds later, and only if the
+ // clipboard is still holding what it put there.
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: secretRow.copied
+ text: "Copied — clears in 45 s"
+ color: Theme.ok
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSizeSmall
+ }
+
+ // Copy is only ever Copy. It used to turn into
+ // Cancel while the row next to it was awaiting a
+ // confirmed Forget, which put the word "Cancel"
+ // where the user had just learnt to find "Copy".
SettingsButton {
- text: secretRow.confirming ? "Cancel" : "Copy"
+ anchors.verticalCenter: parent.verticalCenter
+ text: "Copy"
enabled: !Keyring.working
- onClicked: {
- if (secretRow.confirming) {
- root.confirmingPath = "";
- return;
- }
- Keyring.copy(secretRow.itemPath);
- }
+ onClicked: Keyring.copy(secretRow.itemPath)
}
SettingsButton {
// Two presses, always. Forgetting a stored
// password cannot be undone, and the button
// sits next to Copy where a misclick is cheap.
- text: secretRow.confirming ? "Forget it" : "Forget"
+ anchors.verticalCenter: parent.verticalCenter
+ text: secretRow.confirming ? "Forget it" : "Forget…"
tone: secretRow.confirming ? "danger" : "normal"
enabled: !Keyring.working
onClicked: {
if (!secretRow.confirming) {
- root.confirmingPath = secretRow.itemPath;
+ root.confirmingItem = secretRow.itemPath;
return;
}
- root.confirmingPath = "";
+ root.confirmingItem = "";
Keyring.forget(secretRow.itemPath);
}
}
@@ -251,101 +532,185 @@ SettingsPage {
}
}
+ // ── Traces ───────────────────────────────────────────────────────────────
+ //
+ // Panama's own, now. Each row clears one thing, once, when asked -- there
+ // is no "clean up your machine" button here and no number coloured red,
+ // because none of this is a problem to be solved. It is a list of what is
+ // remembered, with a way to stop remembering it.
+
SettingsCard {
- title: "Camera & microphone"
- subtitle: PrivacyState.anyActive
- ? "In use right now — the bar shows an indicator whenever this is true."
- : "Nothing is using your camera or microphone."
+ title: "Traces"
+ subtitle: "What this machine remembers about what you did. Each row clears itself, once, when you say so."
- TextRow {
- label: "Camera"
- detail: PrivacyState.cameraActive
- ? "In use by " + (PrivacyState.cameraApp || "an application")
- : "Not in use"
- value: PrivacyState.cameraActive ? "Active" : "Idle"
+ SettingRow {
+ id: recentsRow
+
+ readonly property bool confirming: root.confirmingTrace === "recents"
+
+ label: "Recent files"
+ detail: recentsRow.confirming
+ ? "This empties the list. Applications start filling it again as you open things."
+ : "The recents list GTK apps show"
+ + (Traces.measured
+ ? " · " + Traces.recentsEntries + " entr"
+ + (Traces.recentsEntries === 1 ? "y" : "ies")
+ : "")
+ controlWidth: 210
+
+ Row {
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 8
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: !recentsRow.confirming && Traces.measured
+ text: Disks.formatBytes(Traces.recentsBytes)
+ color: Theme.fgDim
+ font.family: Theme.fontFamily
+ font.features: Theme.tabularFigures
+ font.pixelSize: Theme.fontSize
+ }
+
+ SettingsButton {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: recentsRow.confirming
+ text: "Clear it"
+ tone: "danger"
+ enabled: !Traces.busy
+ onClicked: {
+ root.confirmingTrace = "";
+ Traces.clearRecents();
+ }
+ }
+
+ SettingsButton {
+ anchors.verticalCenter: parent.verticalCenter
+ text: recentsRow.confirming ? "Keep" : "Clear…"
+ enabled: !Traces.busy
+ onClicked: root.confirmingTrace = recentsRow.confirming ? "" : "recents"
+ }
+ }
}
- TextRow {
- label: "Microphone"
- detail: PrivacyState.microphoneActive
- ? "In use by " + (PrivacyState.microphoneApp || "an application")
- : "Not in use"
- value: PrivacyState.microphoneActive ? "Active" : "Idle"
+ SettingRow {
+ id: thumbnailsRow
+
+ readonly property bool confirming: root.confirmingTrace === "thumbnails"
+
+ label: "Thumbnails"
+ detail: thumbnailsRow.confirming
+ ? "This empties the cache. File managers rebuild each preview the next time they show it."
+ : "Previews of images you have seen · rebuilt as you browse"
+ controlWidth: 210
+
+ Row {
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 8
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: !thumbnailsRow.confirming && Traces.measured
+ // "over" when the walk ran out of budget: the number is a
+ // floor then, not an answer, and saying so costs one word.
+ text: (Traces.thumbnailsComplete ? "" : "over ")
+ + Disks.formatBytes(Traces.thumbnailsBytes)
+ color: Theme.fgDim
+ font.family: Theme.fontFamily
+ font.features: Theme.tabularFigures
+ font.pixelSize: Theme.fontSize
+ }
+
+ SettingsButton {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: thumbnailsRow.confirming
+ text: "Clear it"
+ tone: "danger"
+ enabled: !Traces.busy
+ onClicked: {
+ root.confirmingTrace = "";
+ Traces.clearThumbnails();
+ }
+ }
+
+ SettingsButton {
+ anchors.verticalCenter: parent.verticalCenter
+ text: thumbnailsRow.confirming ? "Keep" : "Clear…"
+ enabled: !Traces.busy
+ onClicked: root.confirmingTrace = thumbnailsRow.confirming ? "" : "thumbnails"
+ }
+ }
}
+ SettingRow {
+ id: trashRow
+
+ readonly property bool confirming: root.confirmingTrace === "trash"
+
+ visible: root.trashCleanable !== null
+ label: "Trash"
+ detail: trashRow.confirming
+ ? "Emptying is permanent — the files do not go anywhere else first."
+ : "Files you deleted — the same Trash that Storage cleans"
+ controlWidth: 210
+
+ Row {
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 8
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: !trashRow.confirming && Disks.cleanablesMeasured
+ text: Disks.formatBytes(Number(root.trashCleanable?.bytes ?? 0))
+ color: Theme.fgDim
+ font.family: Theme.fontFamily
+ font.features: Theme.tabularFigures
+ font.pixelSize: Theme.fontSize
+ }
+
+ SettingsButton {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: trashRow.confirming
+ text: "Empty it"
+ tone: "danger"
+ enabled: Disks.cleaningId === ""
+ onClicked: {
+ root.confirmingTrace = "";
+ Disks.clean("trash");
+ }
+ }
+
+ SettingsButton {
+ anchors.verticalCenter: parent.verticalCenter
+ text: trashRow.confirming ? "Keep" : "Empty…"
+ enabled: Disks.cleaningId === ""
+ onClicked: root.confirmingTrace = trashRow.confirming ? "" : "trash"
+ }
+ }
+ }
+
+ // A pointer, not a control: the history belongs to Vicinae, which owns
+ // the database Super+V browses. Offering a Clear here would mean
+ // reaching into another program's storage behind its back.
TextRow {
- label: "Screen sharing"
- detail: PrivacyState.screenSharingActive
- ? "Being shared by " + (PrivacyState.screenSharingApp || "an application")
- : "Not being shared"
- value: PrivacyState.screenSharingActive ? "Active" : "Idle"
+ label: "Clipboard history"
+ detail: "Kept by Vicinae, which owns the database Super+V browses. Panama reads that history and does not clear it."
+ value: ""
+ divider: Traces.lastError !== ""
+ }
+
+ SettingRow {
+ visible: Traces.lastError !== ""
+ label: "That did not work"
+ detail: Traces.lastError
divider: false
}
}
- SettingsCard {
- title: "Application permissions"
- // The limit is stated here rather than left to be discovered. Saying
- // "your camera is protected" when a native binary can open it
- // directly would be a claim this page cannot back up.
- subtitle: Permissions.available
- ? "Applications that asked through the desktop portal. Programs installed outside it can still reach these devices directly."
- : (Permissions.lastError || "The desktop portal's permission store is not running.")
-
- Repeater {
- model: Permissions.devices
-
- delegate: Column {
- id: deviceBlock
-
- required property var modelData
- readonly property var applications: deviceBlock.modelData.applications ?? []
-
- width: parent.width
-
- TextRow {
- width: parent.width
- visible: deviceBlock.applications.length === 0
- label: String(deviceBlock.modelData.label ?? "")
- detail: "No application has asked for this."
- value: ""
- }
-
- Repeater {
- model: deviceBlock.applications
-
- delegate: SettingRow {
- required property var modelData
- width: parent.width
- label: String(modelData.app ?? "")
- detail: String(deviceBlock.modelData.label ?? "")
- controlWidth: 150
-
- Row {
- anchors.right: parent.right
- anchors.verticalCenter: parent.verticalCenter
- spacing: 9
-
- SettingsButton {
- anchors.verticalCenter: parent.verticalCenter
- text: "Ask again"
- enabled: !Permissions.busy
- onClicked: Permissions.forget(
- String(deviceBlock.modelData.id), String(modelData.app))
- }
-
- SettingsToggle {
- anchors.verticalCenter: parent.verticalCenter
- checked: modelData.allowed === true
- onToggled: value => Permissions.setAllowed(
- String(deviceBlock.modelData.id), String(modelData.app), value)
- }
- }
- }
- }
- }
- }
- }
+ // ── Device security ──────────────────────────────────────────────────────
SettingsCard {
title: "Device security"
@@ -360,26 +725,44 @@ SettingsPage {
TextRow {
id: factRow
required property var modelData
- required property int index
label: factRow.modelData.label
detail: factRow.modelData.detail
value: factRow.modelData.value
- divider: factRow.index < DeviceSecurity.facts.length - 1
}
}
+
+ ActionRow {
+ label: "Check again"
+ detail: "Re-reads the firmware, the kernel and the filesystem"
+ action: "Refresh"
+ divider: false
+ onTriggered: DeviceSecurity.refresh()
+ }
}
+ // ── Elsewhere ────────────────────────────────────────────────────────────
+ // The screen-lock timings used to be duplicated onto this page, which meant
+ // two sliders writing one preference and no way to tell which one you had
+ // last touched. They live with the idle rules they belong to; this points.
+
SettingsCard {
- title: "Owned by Fedora"
- subtitle: "File history and trash retention are GNOME preferences, applied by a housekeeping service that does not run in a Hyprland session. They are not offered as switches here, because storing that preference would change nothing."
+ title: "Elsewhere"
+ subtitle: "Two neighbours of this page, kept where the rest of their settings are."
ActionRow {
- label: "File history & trash"
- detail: "Opens GNOME Settings, which owns these"
- action: "Open privacy"
+ label: "Screen lock timing"
+ detail: "Lives with the idle rules it belongs to"
+ action: "Open Power & Lock"
+ onTriggered: ShellState.openSettings("power")
+ }
+
+ ActionRow {
+ label: "Per-app notifications"
+ detail: "Which apps may notify, and how much they may show on a locked screen"
+ action: "Open Notifications"
divider: false
- onTriggered: SystemSettings.openGnomePanel("privacy")
+ onTriggered: ShellState.openSettings("notifications")
}
}
}
diff --git a/config/dot/quickshell/modules/settings/README.md b/config/dot/quickshell/modules/settings/README.md
index 9c76d34..a8a7b80 100644
--- a/config/dot/quickshell/modules/settings/README.md
+++ b/config/dot/quickshell/modules/settings/README.md
@@ -295,13 +295,14 @@ the owner instead of duplicating it.
| `cursorInactiveTimeout` | Mouse | Accessibility | Pointer visibility is configured with pointer behavior but affects motor and visual access. |
| `cursorSize` | Accessibility | Mouse | Large cursors are an accessibility adaptation that users also look for beside pointer controls. |
| `inactiveOpacity` | Appearance | Accessibility | Window translucency is an appearance choice with a direct readability impact. |
-| `lockMinutes` | Power | Privacy | Idle timing owns the mechanism; privacy owns the expectation that the unattended desktop locks. |
-| `lockOnSleep` | Power | Privacy | Suspend owns the transition; privacy owns whether waking requires authentication. |
Lock-screen visuals belong only to **Appearance**: background source, blur,
clock, date, user name, and password-field presentation. **Power** owns when
-the session locks, while **Privacy** keeps only the established timing mirrors
-above. Visual controls must not be copied onto either page.
+the session locks — the timings and the suspend transition both, with no mirror
+anywhere. **Privacy** carried a second Screen-lock card until it was removed:
+two sliders writing one preference is not a convenience, it is a page where the
+number you are reading may not be the one you last set. Privacy points at Power
+& Lock instead. Visual controls must not be copied onto either page.
**Displays** is the sole owner of mode, scale, rotation, arrangement, and primary role.
Those values form one safety transaction: every connected output
diff --git a/config/dot/quickshell/modules/settings/SectionLabel.qml b/config/dot/quickshell/modules/settings/SectionLabel.qml
new file mode 100644
index 0000000..6f6813a
--- /dev/null
+++ b/config/dot/quickshell/modules/settings/SectionLabel.qml
@@ -0,0 +1,48 @@
+// A small uppercase heading inside a card, with an optional count beside it.
+//
+// SectionLabel { text: "Camera"; count: "2 apps have asked" }
+//
+// A card whose rows come from four different permission tables needs to say
+// which table a row belongs to, and a second SettingsCard per table would give
+// four headers, four subtitles and four borders to one subject. This is the
+// lighter divider: it groups rows without pretending they are separate topics.
+
+import QtQuick
+import qs.config
+
+Item {
+ id: root
+
+ property string text: ""
+ property string count: ""
+
+ width: parent ? parent.width : 620
+ implicitHeight: 30
+
+ Row {
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ anchors.bottomMargin: 4
+ spacing: 8
+
+ Text {
+ text: root.text
+ color: Theme.fgMuted
+ font.family: Theme.fontFamily
+ font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
+ font.weight: Font.DemiBold
+ font.capitalization: Font.AllUppercase
+ font.letterSpacing: 0.7
+ }
+
+ Text {
+ visible: root.count !== ""
+ text: root.count
+ color: Theme.alpha(Theme.fgMuted, 0.75)
+ font.family: Theme.fontFamily
+ font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
+ font.letterSpacing: 0.7
+ }
+ }
+}
diff --git a/config/dot/quickshell/modules/settings/SshKeysPage.qml b/config/dot/quickshell/modules/settings/SshKeysPage.qml
index ddcbb9c..f40e1ae 100644
--- a/config/dot/quickshell/modules/settings/SshKeysPage.qml
+++ b/config/dot/quickshell/modules/settings/SshKeysPage.qml
@@ -1,12 +1,16 @@
// SSH keys, and what this machine can reach with them.
//
-// Read-heavy on purpose. The genuinely useful things a person wants from a page
-// like this are "which key is this", "is the agent holding it", "copy the public
-// half", and "forget a host whose key changed" -- and all four are safe. What is
-// not here is generating a key, because a passphrase cannot be collected and
-// handed to ssh-keygen without putting it somewhere it should not be, and a
-// page offering to make an unencrypted key instead would be a downgrade
-// disguised as a feature.
+// Private keys are never read here. What the page knows about one is what
+// ssh-keygen will say about it from the outside -- its type, its fingerprint,
+// its comment, whether it has a passphrase, and what its file mode is.
+//
+// Generating a key used to be missing on the grounds that a passphrase could
+// not be collected safely. The grounds were half right: it cannot go in argv,
+// which /proc publishes to every process on the machine, and it cannot go in a
+// temp file. It CAN go down a pipe. The helper reads it from stdin and feeds it
+// to ssh-keygen over a pty, so it exists in two processes' memory and nowhere
+// else -- which is a better place for it than the alternative this page used to
+// leave people with, an unencrypted key made by hand at a terminal.
import Quickshell
import QtQuick
@@ -18,29 +22,82 @@ SettingsPage {
objectName: "ssh-keys"
title: "SSH Keys"
- lede: "The keys this machine signs in with, and the hosts it has met."
+ lede: "The keys this machine signs in with. Private keys are never read — only their public halves and their locks."
property string confirmingForget: ""
- Component.onCompleted: if (!SshKeys.scanned) SshKeys.refresh()
+ // ── The generate form ───────────────────────────────────────────────────
+ property bool showingGenerator: false
+ property string newName: ""
+ property string newComment: ""
+ // Held only while the form is open, and emptied the moment it closes or
+ // succeeds. SecretFieldRow exists for exactly this: PasswordRow's field is
+ // private, so a form that closed left the typed passphrase sitting in a
+ // hidden TextInput for the rest of the session.
+ property string passphrase: ""
+ property string passphraseAgain: ""
- TextRow {
- visible: SshKeys.lastError !== ""
- label: "That did not work"
- detail: SshKeys.lastError
- value: ""
- divider: false
+ // The helper's own rule, mirrored so the button can be dark before the
+ // round trip rather than after it. If these two ever disagree the helper
+ // wins -- it is the one confined to ~/.ssh.
+ readonly property bool nameValid: /^[A-Za-z0-9_.-]{1,64}$/.test(root.newName)
+ readonly property bool nameTaken: SshKeys.keys.some(
+ key => String(key.name ?? "") === root.newName)
+ readonly property bool passphrasesMatch:
+ root.passphrase !== "" && root.passphrase === root.passphraseAgain
+ readonly property bool canCreate:
+ root.nameValid && !root.nameTaken && root.passphrasesMatch && !SshKeys.busy
+
+ readonly property var heldKeys: SshKeys.keys.filter(key => key.loaded === true)
+
+ function resetForm(): void {
+ root.showingGenerator = false;
+ root.newName = "";
+ root.newComment = "";
+ root.passphrase = "";
+ root.passphraseAgain = "";
+ nameField.clear();
+ commentField.clear();
+ passphraseField.clear();
+ confirmField.clear();
}
- TextRow {
+ Component.onCompleted: if (!SshKeys.scanned) SshKeys.refresh()
+
+ // The form closes itself when the key it was asked for turns up in the
+ // snapshot. A failure leaves it open with the name still typed, because the
+ // usual failure is a name already taken and retyping the rest would be a
+ // punishment for the helper's refusal.
+ Connections {
+ target: SshKeys
+
+ function onKeysChanged(): void {
+ if (!root.showingGenerator || root.newName === "")
+ return;
+ if (SshKeys.keys.some(key => String(key.name ?? "") === root.newName))
+ root.resetForm();
+ }
+ }
+
+ // ── What went wrong ─────────────────────────────────────────────────────
+
+ SettingsCard {
+ visible: SshKeys.lastError !== ""
+ title: "That did not work"
+ subtitle: SshKeys.lastError
+ }
+
+ SettingsCard {
visible: SshKeys.scanned && !SshKeys.available
- label: "No SSH directory"
- detail: "Nothing has created ~/.ssh on this machine yet."
- value: ""
- divider: false
+ title: "No SSH directory"
+ subtitle: "Nothing has created ~/.ssh on this machine yet. Generating a key below is one way to."
}
// ── Keys readable by other people ───────────────────────────────────────
+ //
+ // ssh refuses to use a key with these permissions, so it will never be
+ // offered and nothing will say why. This card used to only be able to point
+ // at the problem; it can fix it now.
SettingsCard {
visible: SshKeys.overexposed.length > 0
@@ -52,55 +109,45 @@ SettingsPage {
Repeater {
model: SshKeys.overexposed
- delegate: TextRow {
+ delegate: SettingRow {
+ id: exposedRow
+
required property var modelData
+ required property int index
+
width: parent.width
- label: String(modelData.name ?? "")
- detail: "Mode " + String(modelData.mode ?? "") + " · should be 600"
- value: ""
+ label: String(exposedRow.modelData.name ?? "")
+ detail: "Mode " + String(exposedRow.modelData.mode ?? "") + " · should be 600"
+ controlWidth: 150
+ divider: exposedRow.index < SshKeys.overexposed.length - 1
+
+ SettingsButton {
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ text: "Fix permissions"
+ tone: "accent"
+ enabled: !SshKeys.busy
+ onClicked: SshKeys.fixPermissions(String(exposedRow.modelData.name ?? ""))
+ }
}
}
}
- // ── The agent ───────────────────────────────────────────────────────────
-
- SettingsCard {
- title: "Agent"
- subtitle: SshKeys.agent?.available === true
- ? (SshKeys.agent?.kind === "gnome-keyring"
- ? "The login keyring is holding your keys, and offers every key it finds in ~/.ssh."
- : "An SSH agent is holding your keys for this session.")
- : String(SshKeys.agent?.detail ?? "No SSH agent is running.")
-
- TextRow {
- label: "Holding"
- detail: SshKeys.agent?.available === true
- ? String(SshKeys.agent?.socket ?? "")
- : "Keys will be asked for on every connection"
- value: SshKeys.loadedCount + " key" + (SshKeys.loadedCount === 1 ? "" : "s")
- divider: SshKeys.agent?.kind === "gnome-keyring"
- }
-
- // Said plainly because it is measurable and surprising: ssh-add -d
- // reports success against this agent and the key is still offered a
- // moment later, because it is read back off disk.
- TextRow {
- visible: SshKeys.agent?.kind === "gnome-keyring"
- label: "Removing a key from this agent does not stick"
- detail: "It lists every key in ~/.ssh, so one removed comes straight back. Move the file out of ~/.ssh to stop it being offered."
- value: ""
- divider: false
- }
- }
-
// ── Keys ────────────────────────────────────────────────────────────────
SettingsCard {
title: SshKeys.keys.length === 1 ? "Your key" : "Your keys"
subtitle: SshKeys.keys.length === 0
- ? "No keys in " + SshKeys.directory
+ ? "No keys in " + (SshKeys.directory !== "" ? SshKeys.directory : "~/.ssh")
: "Public halves are safe to share; the private half never leaves this machine."
+ TextRow {
+ visible: SshKeys.scanned && SshKeys.keys.length === 0
+ label: "Nothing here yet"
+ detail: "A key you generate below shows up here, with its fingerprint and whether the agent is holding it."
+ value: ""
+ }
+
Repeater {
model: SshKeys.keys
@@ -110,16 +157,20 @@ SettingsPage {
required property var modelData
required property int index
+ readonly property bool copied:
+ SshKeys.copiedKey !== ""
+ && SshKeys.copiedKey === String(keyRow.modelData.publicPath ?? "")
+
label: String(keyRow.modelData.name ?? "")
detail: String(keyRow.modelData.type ?? "") + " · "
+ String(keyRow.modelData.fingerprint ?? "")
+ (String(keyRow.modelData.comment ?? "") !== ""
? " · " + keyRow.modelData.comment : "")
+ (keyRow.modelData.encrypted === true
- ? " · passphrase protected"
+ ? " · passphrase set"
: (keyRow.modelData.encrypted === false ? " · no passphrase" : ""))
- divider: keyRow.index < SshKeys.keys.length - 1
- controlWidth: 220
+ divider: true
+ controlWidth: 290
Row {
anchors.right: parent.right
@@ -128,7 +179,16 @@ SettingsPage {
Text {
anchors.verticalCenter: parent.verticalCenter
- visible: keyRow.modelData.loaded === true
+ visible: keyRow.copied
+ text: "Copied"
+ color: Theme.ok
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSizeSmall
+ }
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: keyRow.modelData.loaded === true && !keyRow.copied
text: "In the agent"
color: Theme.fgDim
font.family: Theme.fontFamily
@@ -152,14 +212,175 @@ SettingsPage {
}
}
}
+
+ ActionRow {
+ label: "Generate a key"
+ detail: "ed25519, with a passphrase — collected here and handed to ssh-keygen over a pty, never on a command line"
+ action: root.showingGenerator ? "Cancel" : "Generate…"
+ divider: root.showingGenerator
+ onTriggered: {
+ if (root.showingGenerator) {
+ root.resetForm();
+ return;
+ }
+ root.showingGenerator = true;
+ }
+ }
+
+ LiveFieldRow {
+ id: nameField
+
+ visible: root.showingGenerator
+ label: "File name"
+ detail: root.newName === ""
+ ? "Letters, numbers, dot, dash and underscore. It is written into " + (SshKeys.directory !== "" ? SshKeys.directory : "~/.ssh") + "."
+ : (!root.nameValid
+ ? "Only letters, numbers, dot, dash and underscore, up to 64 characters."
+ : (root.nameTaken
+ ? "A key by that name is already there, and it will not be written over."
+ : "Written as " + root.newName + " and " + root.newName + ".pub"))
+ invalid: root.newName !== "" && (!root.nameValid || root.nameTaken)
+ placeholder: "id_ed25519_forge"
+ text: root.newName
+ maximumLength: 64
+ onEdited: value => root.newName = value
+ }
+
+ LiveFieldRow {
+ id: commentField
+
+ visible: root.showingGenerator
+ label: "Comment"
+ detail: "Written into the public half, so a server's authorized_keys says which key this is"
+ placeholder: "you@machine"
+ text: root.newComment
+ maximumLength: 128
+ onEdited: value => root.newComment = value
+ }
+
+ SecretFieldRow {
+ id: passphraseField
+
+ visible: root.showingGenerator
+ label: "Passphrase"
+ detail: "Required. A key with no passphrase is a password file that anyone reading the disk can use."
+ placeholder: "Passphrase"
+ onChanged: value => root.passphrase = value
+ }
+
+ SecretFieldRow {
+ id: confirmField
+
+ visible: root.showingGenerator
+ label: "Confirm"
+ detail: root.passphraseAgain === ""
+ ? "Type it again"
+ : (root.passphrasesMatch ? "Matches" : "These two do not match yet.")
+ placeholder: "Passphrase again"
+ onChanged: value => root.passphraseAgain = value
+ }
+
+ ActionRow {
+ visible: root.showingGenerator
+ label: "Create key"
+ detail: SshKeys.generating
+ ? "ssh-keygen is working. It takes a moment."
+ : (!root.nameValid
+ ? "Give the key a file name first."
+ : (root.nameTaken
+ ? "That name is taken."
+ : (root.passphrase === ""
+ ? "Choose a passphrase."
+ : (!root.passphrasesMatch
+ ? "The two passphrases do not match."
+ : "ed25519, written to " + (SshKeys.directory !== "" ? SshKeys.directory : "~/.ssh") + "/" + root.newName))))
+ action: SshKeys.generating ? "Making the key…" : "Create key"
+ enabled: root.canCreate
+ divider: false
+ onTriggered: {
+ if (!root.canCreate)
+ return;
+ SshKeys.generate(root.newName, root.newComment, root.passphrase);
+ // Out of the fields immediately, whatever happens next. The
+ // name stays so a refused write can be retried without
+ // retyping everything.
+ root.passphrase = "";
+ root.passphraseAgain = "";
+ passphraseField.clear();
+ confirmField.clear();
+ }
+ }
+ }
+
+ // ── The agent ───────────────────────────────────────────────────────────
+
+ SettingsCard {
+ title: "Agent"
+ subtitle: SshKeys.agent?.available === true
+ ? (SshKeys.agentKind === "gnome-keyring"
+ ? "The login keyring is holding your keys, and offers every key it finds in ~/.ssh."
+ : "An SSH agent is holding your keys for this session.")
+ : String(SshKeys.agent?.detail ?? "No SSH agent is running.")
+
+ TextRow {
+ label: "Holding"
+ detail: SshKeys.agent?.available === true
+ ? String(SshKeys.agent?.socket ?? "")
+ : "Keys will be asked for on every connection"
+ value: SshKeys.loadedCount + " key" + (SshKeys.loadedCount === 1 ? "" : "s")
+ }
+
+ TextRow {
+ visible: SshKeys.agent?.available === true && root.heldKeys.length === 0
+ label: "Nothing loaded"
+ detail: "Add a key above and the agent will offer it without asking for its passphrase again."
+ value: ""
+ }
+
+ Repeater {
+ model: root.heldKeys
+
+ delegate: SettingRow {
+ id: heldRow
+
+ required property var modelData
+
+ width: parent.width
+ label: String(heldRow.modelData.name ?? "")
+ detail: String(heldRow.modelData.fingerprint ?? "")
+ controlWidth: 110
+
+ SettingsButton {
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ text: "Remove"
+ enabled: !SshKeys.busy
+ onClicked: SshKeys.removeFromAgent(String(heldRow.modelData.path ?? ""))
+ }
+ }
+ }
+
+ // Said plainly because it is measurable and surprising: ssh-add -d
+ // reports success against this agent and the key is still offered a
+ // moment later, because it is read back off disk. The Remove button
+ // above stays -- the helper refuses and says this, which is a better
+ // answer than a button that is not there.
+ TextRow {
+ visible: SshKeys.agent?.available === true && !SshKeys.durableRemoval
+ label: "Removing a key from this agent does not stick"
+ detail: "It lists every key in ~/.ssh, so one removed comes straight back at the next sign-in. That is the keyring agent's design, not a bug — move the file out of ~/.ssh to stop it being offered."
+ value: ""
+ divider: false
+ }
}
// ── Known hosts ─────────────────────────────────────────────────────────
SettingsCard {
- visible: SshKeys.hosts.length > 0
title: "Known hosts"
- subtitle: "Machines this one has connected to before. Forgetting one means being asked to trust it again."
+ subtitle: SshKeys.hosts.length === 0
+ ? "Nothing yet. A host is recorded the first time you accept its key."
+ : "Machines this one has connected to. Forget an entry when a server legitimately changed — ssh-keygen keeps a .old copy."
Repeater {
model: SshKeys.hosts
@@ -168,11 +389,11 @@ SettingsPage {
id: hostRow
required property var modelData
- required property int index
readonly property bool confirming:
root.confirmingForget === String(hostRow.modelData.host ?? "")
+ width: parent.width
label: hostRow.modelData.hashed === true
? hostRow.modelData.count + " hashed entries"
: String(hostRow.modelData.host ?? "")
@@ -181,7 +402,6 @@ SettingsPage {
: (hostRow.confirming
? "You will be asked to trust this host the next time you connect."
: (hostRow.modelData.types ?? []).join(", "))
- divider: hostRow.index < SshKeys.hosts.length - 1
controlWidth: 190
Row {
@@ -212,5 +432,14 @@ SettingsPage {
}
}
}
+
+ ActionRow {
+ label: "Check again"
+ detail: "Re-reads ~/.ssh and asks the agent what it is holding"
+ action: SshKeys.busy ? "Reading…" : "Refresh"
+ enabled: !SshKeys.busy
+ divider: false
+ onTriggered: SshKeys.refresh()
+ }
}
}
diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir
index 740bef3..f38277f 100644
--- a/config/dot/quickshell/modules/settings/qmldir
+++ b/config/dot/quickshell/modules/settings/qmldir
@@ -111,6 +111,8 @@ HomeLightTile 1.0 HomeLightTile.qml
DictationPage 1.0 DictationPage.qml
TextEntryRow 1.0 TextEntryRow.qml
PrivacyPage 1.0 PrivacyPage.qml
+PrivacyLiveTile 1.0 PrivacyLiveTile.qml
+SectionLabel 1.0 SectionLabel.qml
RegionPage 1.0 RegionPage.qml
SearchPicker 1.0 SearchPicker.qml
OnlineAccountsPage 1.0 OnlineAccountsPage.qml
diff --git a/config/dot/quickshell/scripts/panama-permissions b/config/dot/quickshell/scripts/panama-permissions
index 46b40be..0031b1e 100755
--- a/config/dot/quickshell/scripts/panama-permissions
+++ b/config/dot/quickshell/scripts/panama-permissions
@@ -1,21 +1,40 @@
#!/usr/bin/env python3
-"""Which applications may use the camera and microphone.
+"""Which applications the desktop portal has recorded an answer for.
Read from xdg-desktop-portal's permission store, which is where an application
-that asks through the portal has its answer recorded. That is the whole of what
-this can control, and the limit is worth stating plainly rather than implying a
-protection that does not exist: a native binary opens /dev/video0 directly and
-no desktop setting stands in its way. What this covers is Flatpaks and anything
-else that goes through the portal -- which on this machine is most of what would
-ever ask.
+that asks through the portal has its answer written down. That is the whole of
+what this can control, and the limit is worth stating plainly rather than
+implying a protection that does not exist: a native binary opens /dev/video0
+directly and no desktop setting stands in its way. What this covers is Flatpaks
+and anything else that goes through the portal -- which on this machine is most
+of what would ever ask.
-Devices with no recorded application are reported as empty rather than omitted,
+Six subjects, mapped onto the store's own layout rather than onto a tidier one:
+
+ camera, microphone entries of the store's `devices` table, keyed by the
+ device name. Plain yes/no, so they can be toggled.
+ background the `background` table's single `background` entry, also
+ plain yes/no. This is "may run when you closed it".
+ screencast its own table, and remote-desktop likewise -- but their
+ remote-desktop ids are opaque restore tokens, one per remembered
+ session, and the value beside each grant is a structured
+ GVariant describing which monitor or which input devices
+ were shared. Nothing here can rebuild one of those, so
+ these report which application holds grants and offer
+ only to drop them. There is deliberately no code path
+ that Sets them: a toggle that cannot be honoured is worse
+ than no toggle.
+ location listed only. geoclue is absent on this machine, so the
+ table is normally empty and the page omits the section
+ rather than showing an empty one.
+
+A table nothing has asked for is reported as an empty list rather than omitted,
so the page can say "nothing has asked" instead of showing nothing at all.
panama-permissions snapshot
- panama-permissions set DEVICE APP_ID allow|deny
- panama-permissions forget DEVICE APP_ID
+ panama-permissions set TABLE APP_ID true|false
+ panama-permissions forget TABLE APP_ID
"""
from __future__ import annotations
@@ -24,24 +43,58 @@ import json
import re
import subprocess
import sys
-
-TABLE = "devices"
-
-# The devices the portal arbitrates. Listed rather than discovered so a device
-# nothing has asked for still appears, which is the difference between "no
-# application uses your microphone" and a page that silently omits it.
-DEVICES = (
- ("camera", "Camera"),
- ("microphone", "Microphone"),
- ("speakers", "Speakers"),
-)
+from dataclasses import dataclass
APP_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
-DEVICE_ID = re.compile(r"^[a-z]+$")
+TABLE_ID = re.compile(r"^[a-z][a-z-]{0,31}$")
+
+# The store's own ids for a remembered session are opaque tokens, so they are
+# never accepted from a caller -- only discovered by listing the table.
+ENTRY_ID = re.compile(r"^[A-Za-z0-9._-]{1,128}$")
ALLOWED = "yes"
DENIED = "no"
+SIMPLE = "simple"
+STRUCTURED = "structured"
+READONLY = "readonly"
+
+
+@dataclass(frozen=True)
+class Table:
+ """One subject on the page, and where the store keeps it.
+
+ `portal` is the store's table name, which is not always the subject's name:
+ camera and microphone both live in `devices`. `entries` names the store ids
+ to look under, or None to discover them by listing the table -- which is the
+ only option for screencast and remote-desktop, whose ids are restore tokens.
+ `writable` is the id a Set writes to, and is None for everything this
+ refuses to write.
+ """
+
+ name: str
+ portal: str
+ entries: tuple[str, ...] | None
+ writable: str | None
+ kind: str
+
+
+TABLES: tuple[Table, ...] = (
+ Table("camera", "devices", ("camera",), "camera", SIMPLE),
+ Table("microphone", "devices", ("microphone",), "microphone", SIMPLE),
+ Table("screencast", "screencast", None, None, STRUCTURED),
+ Table("remote-desktop", "remote-desktop", None, None, STRUCTURED),
+ Table("background", "background", None, "background", SIMPLE),
+ Table("location", "location", None, None, READONLY),
+)
+
+BY_NAME = {table.name: table for table in TABLES}
+
+# Named once, here, so the refusal below reads as a list membership rather than
+# as a chain of conditions someone could later add an exception to.
+SIMPLE_TABLES = tuple(table.name for table in TABLES if table.kind == SIMPLE)
+REVOKE_ONLY_TABLES = tuple(table.name for table in TABLES if table.kind == STRUCTURED)
+
class BoundaryError(RuntimeError):
"""A user-visible validation or permission-store failure."""
@@ -71,7 +124,7 @@ def portal_call(method: str, signature: str, *arguments: str) -> dict | None:
])
if result.returncode != 0:
detail = (result.stderr or "").strip()
- # A device nothing has ever asked for has no row at all, and the store
+ # An entry nothing has ever asked for has no row at all, and the store
# says so as "No entry for camera". Matched on the store's actual words
# rather than a guess at them.
lowered = detail.lower()
@@ -89,44 +142,91 @@ def available() -> bool:
return "org.freedesktop.impl.portal.PermissionStore" in (result.stdout or "")
-def entries_for(device: str) -> list[dict]:
- payload = portal_call("Lookup", "ss", TABLE, device)
+def entry_ids(table: Table) -> list[str]:
+ """The store ids to look under for one subject.
+
+ Fixed for the device entries, discovered for everything else. A discovered
+ id that does not look like an id is dropped rather than passed back into the
+ store.
+ """
+ if table.entries is not None:
+ return list(table.entries)
+ payload = portal_call("List", "s", table.portal)
if not payload:
return []
data = payload.get("data") or []
- if not data or not isinstance(data[0], dict):
- return []
+ listed = data[0] if data and isinstance(data[0], list) else []
+ return [str(value) for value in listed if ENTRY_ID.match(str(value))]
- found = []
- for app_id, permissions in data[0].items():
- values = [str(value) for value in (permissions or [])]
- found.append({
- "app": app_id,
- # Anything that is not an explicit "yes" is treated as withheld:
- # guessing generously about a camera is the wrong way to be wrong.
- "allowed": ALLOWED in values,
- "raw": ",".join(values),
- })
- found.sort(key=lambda entry: entry["app"].casefold())
- return found
+
+def rows_for(table: Table) -> list[dict]:
+ """One row per application, folded across every entry in the table.
+
+ screencast keeps a separate entry per remembered session, so an application
+ that has shared its screen four times appears four times in the store. The
+ page is answering "may this application share your screen", which is one
+ question, so the rows are folded by application and `grants` says how many
+ stored sessions are behind the row.
+ """
+ folded: dict[str, dict] = {}
+ for entry in entry_ids(table):
+ payload = portal_call("Lookup", "ss", table.portal, entry)
+ if not payload:
+ continue
+ data = payload.get("data") or []
+ recorded = data[0] if data and isinstance(data[0], dict) else {}
+ if not isinstance(recorded, dict):
+ continue
+ for app_id, permissions in recorded.items():
+ values = [str(value) for value in (permissions or [])]
+ row = folded.setdefault(str(app_id), {
+ "app": str(app_id),
+ # Anything that is not an explicit "yes" is treated as withheld:
+ # guessing generously about a camera is the wrong way to be wrong.
+ "allowed": False,
+ "grants": 0,
+ "raw": "",
+ })
+ row["grants"] += 1
+ if ALLOWED in values:
+ row["allowed"] = True
+ if row["raw"] == "":
+ row["raw"] = ",".join(values)
+
+ rows = list(folded.values())
+ rows.sort(key=lambda row: row["app"].casefold())
+ return rows
def snapshot() -> dict:
if not available():
return {
"available": False,
- "devices": [],
+ "tables": {table.name: [] for table in TABLES},
+ "simpleTables": list(SIMPLE_TABLES),
+ "revokeOnlyTables": list(REVOKE_ONLY_TABLES),
"error": "The desktop portal's permission store is not running.",
}
- devices = []
- for device_id, label in DEVICES:
- devices.append({
- "id": device_id,
- "label": label,
- "applications": entries_for(device_id),
- })
- return {"available": True, "devices": devices, "error": ""}
+ tables: dict[str, list[dict]] = {}
+ problems: list[str] = []
+ for table in TABLES:
+ # One unreadable table must not take the other five down with it: a page
+ # that shows nothing because location is broken is worse than a page
+ # that shows five subjects and says location could not be read.
+ try:
+ tables[table.name] = rows_for(table)
+ except BoundaryError as error:
+ tables[table.name] = []
+ problems.append(f"{table.name}: {error}")
+
+ return {
+ "available": True,
+ "tables": tables,
+ "simpleTables": list(SIMPLE_TABLES),
+ "revokeOnlyTables": list(REVOKE_ONLY_TABLES),
+ "error": "; ".join(problems),
+ }
def require(pattern: re.Pattern[str], value: str, message: str) -> str:
@@ -135,22 +235,59 @@ def require(pattern: re.Pattern[str], value: str, message: str) -> str:
return value
-def set_permission(device: str, app: str, allowed: bool) -> None:
- require(DEVICE_ID, device, "That is not a device.")
+def named(name: str) -> Table:
+ require(TABLE_ID, name, "That is not a permission table.")
+ table = BY_NAME.get(name)
+ if table is None:
+ raise BoundaryError("That is not a permission table this manages.")
+ return table
+
+
+def set_permission(name: str, app: str, allowed: bool) -> None:
+ """Record an answer. Only for the subjects whose value is a plain yes or no.
+
+ screencast and remote-desktop are refused here and have no other route to a
+ Set anywhere in this file. Their stored value is a structured description of
+ a session -- which monitor, which input devices -- and writing a bare "yes"
+ over it would leave the store holding a grant the portal cannot restore.
+ Dropping the grant is the honest operation, so that is the only one offered.
+ """
+ table = named(name)
require(APP_ID, app, "That is not an application.")
- if not any(device == known for known, _ in DEVICES):
- raise BoundaryError("That is not a device this manages.")
+ if table.name not in SIMPLE_TABLES or table.writable is None:
+ if table.kind == STRUCTURED:
+ raise BoundaryError(
+ f"A {table.name} grant describes a whole session, so it can only "
+ "be revoked, not switched on and off.")
+ raise BoundaryError(f"{table.name} permissions are shown, not changed, here.")
# Permissions are an array of strings, so busctl needs the element count
# before the element -- "1 yes", not "yes".
- portal_call("SetPermission", "sbssas", TABLE, "true", device, app,
+ portal_call("SetPermission", "sbssas", table.portal, "true", table.writable, app,
"1", ALLOWED if allowed else DENIED)
-def forget(device: str, app: str) -> None:
- """Drop the recorded answer, so the application is asked again next time."""
- require(DEVICE_ID, device, "That is not a device.")
+def forget(name: str, app: str) -> None:
+ """Drop the recorded answer, so the application is asked again next time.
+
+ Every entry the application appears under, because one application can hold
+ several remembered screencast sessions and dropping one of them would leave
+ the row on the page looking unchanged.
+ """
+ table = named(name)
require(APP_ID, app, "That is not an application.")
- portal_call("DeletePermission", "sss", TABLE, device, app)
+ dropped = 0
+ for entry in entry_ids(table):
+ payload = portal_call("Lookup", "ss", table.portal, entry)
+ if not payload:
+ continue
+ data = payload.get("data") or []
+ recorded = data[0] if data and isinstance(data[0], dict) else {}
+ if not isinstance(recorded, dict) or app not in recorded:
+ continue
+ portal_call("DeletePermission", "sss", table.portal, entry, app)
+ dropped += 1
+ if dropped == 0:
+ raise BoundaryError("There was no recorded answer to forget.")
def main(arguments: list[str]) -> int:
@@ -160,20 +297,25 @@ def main(arguments: list[str]) -> int:
return 0
if len(arguments) == 4 and arguments[0] == "set":
- if arguments[3] not in ("allow", "deny"):
- raise BoundaryError("That is not allow or deny.")
- set_permission(arguments[1], arguments[2], arguments[3] == "allow")
+ if arguments[3] not in ("true", "false"):
+ raise BoundaryError("That is not true or false.")
+ set_permission(arguments[1], arguments[2], arguments[3] == "true")
elif len(arguments) == 3 and arguments[0] == "forget":
forget(arguments[1], arguments[2])
else:
raise BoundaryError(
- "Usage: panama-permissions snapshot | set DEVICE APP allow|deny | "
- "forget DEVICE APP")
+ "Usage: panama-permissions snapshot | set TABLE APP true|false | "
+ "forget TABLE APP")
except BoundaryError as error:
try:
state = snapshot()
except BoundaryError:
- state = {"available": False, "devices": []}
+ state = {
+ "available": False,
+ "tables": {table.name: [] for table in TABLES},
+ "simpleTables": list(SIMPLE_TABLES),
+ "revokeOnlyTables": list(REVOKE_ONLY_TABLES),
+ }
state["error"] = str(error)
print(json.dumps(state, separators=(",", ":")))
return 0
diff --git a/config/dot/quickshell/scripts/panama-privacy b/config/dot/quickshell/scripts/panama-privacy
new file mode 100755
index 0000000..f556720
--- /dev/null
+++ b/config/dot/quickshell/scripts/panama-privacy
@@ -0,0 +1,269 @@
+#!/usr/bin/env python3
+
+"""What this desktop remembers about what you opened, and how to forget it.
+
+Two traces, both of them a normal and useful part of a desktop rather than a
+problem to be alarmed about. This tool measures them and clears them on request.
+It never clears anything on its own, never runs on a schedule, and reports sizes
+without ever suggesting that a number is too big -- the software that tells you
+your machine is dirty is trying to sell you something.
+
+ traces what each trace currently costs, measured now.
+ clear-recents the recent-files list, replaced with an empty but valid
+ document. Not deleted: GTK recreates the file the moment
+ something opens a file anyway, and an empty valid file takes
+ effect in every running application immediately, where a
+ missing one is only noticed on the next write.
+ clear-thumbnails the contents of the thumbnail cache. The folder stays; only
+ what is inside it goes, and nothing is followed out of it.
+
+Trash is deliberately not here. It is measured and emptied by panama-disks, and
+one trash implementation is the right number to have.
+
+Seams, for tests that must not touch a real home directory:
+
+ PANAMA_PRIVACY_RECENTS the recent-files document
+ PANAMA_PRIVACY_THUMBNAILS the thumbnail cache folder
+
+Both still have to resolve to somewhere inside HOME, so a test points HOME at a
+scratch directory rather than pointing these at one.
+
+ panama-privacy traces
+ panama-privacy clear-recents
+ panama-privacy clear-thumbnails
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import re
+import shutil
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+# Measuring a thumbnail cache means walking it, and a machine that has browsed a
+# large picture library has a lot of it. Bounded so the page cannot hang; a walk
+# that runs out of time reports what it had and says it is a floor.
+MEASURE_TIMEOUT_SECONDS = 20.0
+
+# The document GTK keeps the recent-files list in, and the cache every file
+# manager and image viewer on this desktop shares.
+RECENTS_RELATIVE = "recently-used.xbel"
+THUMBNAILS_RELATIVE = "thumbnails"
+
+# What an emptied recent-files list looks like. Byte for byte the header GTK
+# writes itself, so the file this leaves behind is one GTK would have written.
+EMPTY_XBEL = (
+ '\n'
+ '\n'
+ '\n'
+)
+
+BOOKMARK = re.compile(rb" Path:
+ return Path(os.path.expanduser("~")).resolve(strict=False)
+
+
+def data_home() -> Path:
+ configured = os.environ.get("XDG_DATA_HOME") or ""
+ if configured:
+ return Path(configured)
+ return Path(os.path.expanduser("~")) / ".local" / "share"
+
+
+def cache_home() -> Path:
+ configured = os.environ.get("XDG_CACHE_HOME") or ""
+ if configured:
+ return Path(configured)
+ return Path(os.path.expanduser("~")) / ".cache"
+
+
+def recents_path() -> Path:
+ override = os.environ.get("PANAMA_PRIVACY_RECENTS") or ""
+ return Path(override) if override else data_home() / RECENTS_RELATIVE
+
+
+def thumbnails_path() -> Path:
+ override = os.environ.get("PANAMA_PRIVACY_THUMBNAILS") or ""
+ return Path(override) if override else cache_home() / THUMBNAILS_RELATIVE
+
+
+def confined(target: Path, description: str) -> Path:
+ """A path this is allowed to write to, or a refusal.
+
+ The same guard panama-disks uses on the cache folder, and for the same
+ reason: this function is the whole reason the buttons on the page are safe
+ to press. A symlink is refused outright rather than followed, and anything
+ that resolves to the home directory itself or to somewhere outside it is
+ refused -- so an XDG variable pointing somewhere alarming, or a cache folder
+ someone linked to /, cannot turn one click into a deleted system.
+ """
+ if target.is_symlink():
+ raise BoundaryError(f"{description} is a link, so it will not be touched.")
+ resolved = target.resolve(strict=False)
+ root = home()
+ if resolved == root or root not in resolved.parents:
+ raise BoundaryError(f"{description} is not inside your home folder.")
+ return resolved
+
+
+def measure(path: Path, budget: float) -> tuple[int, float, bool]:
+ """Bytes used, what is left of the budget, and whether the walk finished."""
+ if not path.exists():
+ return 0, budget, True
+ if budget <= 1.0:
+ return 0, budget, False
+ started = time.monotonic()
+ try:
+ completed = subprocess.run(["du", "-sxb", str(path)], check=False,
+ capture_output=True, text=True, timeout=budget)
+ except (OSError, subprocess.TimeoutExpired):
+ # A walk that ran out of time has consumed the whole budget by
+ # definition; the caller stops rather than starting another.
+ return 0, 0.0, False
+ left = max(budget - (time.monotonic() - started), 0.0)
+ if completed.returncode != 0:
+ return 0, left, False
+ match = re.match(r"^(\d+)", completed.stdout)
+ return (int(match.group(1)) if match else 0), left, match is not None
+
+
+def recent_entries(path: Path) -> int:
+ """How many files the recent list remembers.
+
+ Counted by scanning for the opening tag rather than parsing the document:
+ the answer wanted is a count, and an XML parser here would build a tree of
+ every path the user has opened in order to throw it away again.
+ """
+ try:
+ with path.open("rb") as document:
+ return sum(len(BOOKMARK.findall(chunk))
+ for chunk in iter(lambda: document.read(65536), b""))
+ except OSError:
+ return 0
+
+
+def traces() -> dict:
+ budget = MEASURE_TIMEOUT_SECONDS
+
+ recents = recents_path()
+ recents_present = recents.is_file()
+ recents_bytes = recents.stat().st_size if recents_present else 0
+
+ thumbnails = thumbnails_path()
+ thumbnails_present = thumbnails.is_dir()
+ thumbnails_bytes, budget, complete = measure(thumbnails, budget)
+
+ return {
+ "recents": {
+ "bytes": int(recents_bytes),
+ "entries": recent_entries(recents) if recents_present else 0,
+ "path": str(recents),
+ "present": recents_present,
+ },
+ "thumbnails": {
+ "bytes": int(thumbnails_bytes),
+ "path": str(thumbnails),
+ "present": thumbnails_present,
+ # False when the walk ran out of time, which makes the byte count a
+ # floor rather than an answer. The page says so instead of quoting a
+ # number it cannot stand behind.
+ "measured": complete,
+ },
+ "error": "",
+ }
+
+
+def clear_recents() -> None:
+ """Replace the recent-files list with an empty one.
+
+ Never unlinked. GTK holds the path open and recreates the document on its
+ next write, so deleting it buys nothing an empty document does not, and an
+ empty document is understood by everything reading the list right now.
+ """
+ target = recents_path()
+ if not target.exists():
+ # Nothing remembered is the state this was asked to produce.
+ return
+ if not target.is_file():
+ raise BoundaryError("The recent-files list is not a file.")
+ resolved = confined(target, "The recent-files list")
+ try:
+ resolved.write_text(EMPTY_XBEL, encoding="utf-8")
+ except OSError as error:
+ raise BoundaryError("The recent-files list could not be emptied.") from error
+
+
+def clear_thumbnails() -> None:
+ """Delete what is inside the thumbnail cache, never following a link out.
+
+ A thumbnail an application still has open cannot always be removed, and that
+ is the normal case rather than a failure -- so a partial pass succeeds, and
+ the freshly measured size the caller gets back says how much is left. Only a
+ pass that removed nothing at all is reported as a failure.
+ """
+ target = thumbnails_path()
+ if not target.exists():
+ return
+ if not target.is_dir():
+ raise BoundaryError("The thumbnail cache is not a folder.")
+ directory = confined(target, "The thumbnail cache")
+
+ removed = 0
+ failures = 0
+ with os.scandir(directory) as entries:
+ for entry in entries:
+ try:
+ # is_symlink first: a symlinked directory must be unlinked, not
+ # walked, or this deletes whatever it points at.
+ if entry.is_symlink() or not entry.is_dir(follow_symlinks=False):
+ os.unlink(entry.path)
+ else:
+ # rmtree lstats as it goes and refuses to descend a symlink.
+ shutil.rmtree(entry.path, ignore_errors=False)
+ removed += 1
+ except OSError:
+ failures += 1
+ if failures and not removed:
+ raise BoundaryError("The thumbnail cache is in use and nothing could be removed.")
+
+
+def main(arguments: list[str]) -> int:
+ try:
+ if arguments == ["traces"]:
+ pass
+ elif arguments == ["clear-recents"]:
+ clear_recents()
+ elif arguments == ["clear-thumbnails"]:
+ clear_thumbnails()
+ else:
+ raise BoundaryError(
+ "Usage: panama-privacy traces | clear-recents | clear-thumbnails")
+ except BoundaryError as error:
+ try:
+ state = traces()
+ except OSError:
+ state = {"recents": {"bytes": 0, "entries": 0, "path": "", "present": False},
+ "thumbnails": {"bytes": 0, "path": "", "present": False, "measured": False}}
+ state["error"] = str(error)
+ print(json.dumps(state, separators=(",", ":")))
+ return 0
+
+ print(json.dumps(traces(), separators=(",", ":")))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv[1:]))
diff --git a/config/dot/quickshell/scripts/panama-ssh-keys b/config/dot/quickshell/scripts/panama-ssh-keys
index 035265f..1b5cd4b 100755
--- a/config/dot/quickshell/scripts/panama-ssh-keys
+++ b/config/dot/quickshell/scripts/panama-ssh-keys
@@ -8,13 +8,21 @@ ssh-keygen to derive the PUBLIC key with an empty passphrase: it succeeds for an
unencrypted key and fails for an encrypted one, and either way the only thing it
can print is public material.
-No passphrase passes through this tool at all. Adding an encrypted key to the
-agent lets ssh-add prompt through the system's own askpass, which is where that
-belongs -- a settings page collecting a passphrase and handing it on would be a
-worse place for it to live, and putting one in argv would publish it to every
-process on the machine.
+A passphrase enters this tool in exactly one place -- creating a key -- and it
+enters on standard input, which no other process can read. From there it is
+typed at ssh-keygen over a pseudo-terminal, the same way a person would type it,
+because the two obvious alternatives are both worse: a passphrase in argv is
+published to every process on the machine, and a passphrase in a temporary file
+is written to disk. It is never logged, never echoed back, and never included in
+an error message.
+
+Adding an existing encrypted key to the agent is different: no passphrase is
+collected for that at all, because ssh-add prompts through the system's own
+askpass, which is where that belongs.
panama-ssh-keys snapshot
+ panama-ssh-keys generate NAME COMMENT (passphrase on stdin)
+ panama-ssh-keys fix-permissions NAME
panama-ssh-keys agent-add PATH | agent-remove PATH
panama-ssh-keys forget-host HOST
"""
@@ -23,9 +31,14 @@ from __future__ import annotations
import json
import os
+import pty
import re
+import select
+import shutil
+import signal
import subprocess
import sys
+import time
from pathlib import Path
SSH_DIR = Path.home() / ".ssh"
@@ -34,6 +47,23 @@ KNOWN_HOSTS = SSH_DIR / "known_hosts"
# A host as it may appear in known_hosts, including [host]:port forms.
HOST = re.compile(r"^[A-Za-z0-9._:\[\]-]{1,253}$")
+# A key file name, and nothing that could be a path. No slash is in the class,
+# so a name cannot describe another directory at all -- the resolve-and-compare
+# below is the second lock on the same door rather than the only one.
+KEY_NAME = re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
+
+# A key comment. Free text, but nothing that could break out of a terminal line
+# or be mistaken for one of ssh-keygen's own prompts.
+KEY_COMMENT = re.compile(r"^[^\x00-\x1f\x7f]{0,128}$")
+
+# ssh-keygen's own floor. Checked here so the refusal arrives before a terminal
+# is opened, rather than as a re-prompt nobody is there to answer.
+MINIMUM_PASSPHRASE = 5
+
+# Generating an ed25519 key takes milliseconds. The budget is this large only so
+# that a machine starved of entropy fails with a message rather than a hang.
+KEYGEN_TIMEOUT_SECONDS = 120.0
+
# gnome-keyring's agent, which is what runs on this desktop. Only used when the
# environment has not already named one, so an ssh-agent started by hand wins.
KEYRING_SOCKET = Path(os.environ.get("XDG_RUNTIME_DIR", "/run/user/1000")) / "keyring" / "ssh"
@@ -253,6 +283,217 @@ def agent_remove(path: str) -> None:
raise BoundaryError(detail[-1] if detail else "That key could not be removed.")
+def resolve_new_key(name: str) -> Path:
+ """Where a key by this name would go, or a refusal.
+
+ Refuses anything that already exists -- both halves, because a stray .pub
+ beside no private key still means ssh-keygen would be asked to overwrite,
+ and this tool does not overwrite keys. Losing a private key is not
+ recoverable and a settings page is the wrong place to learn that.
+ """
+ if not KEY_NAME.match(name or "") or name in (".", ".."):
+ raise BoundaryError(
+ "A key name can use letters, numbers, dots, dashes and underscores.")
+ if name.endswith(".pub"):
+ raise BoundaryError("Name the key itself, not its public half.")
+
+ try:
+ SSH_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
+ directory = SSH_DIR.resolve(strict=True)
+ except OSError as error:
+ raise BoundaryError("The SSH directory could not be opened.") from error
+ if not directory.is_dir():
+ raise BoundaryError("The SSH directory is not a directory.")
+
+ target = directory / name
+ if target.parent.resolve(strict=True) != directory:
+ raise BoundaryError("That key is not in the SSH directory.")
+
+ public = Path(str(target) + ".pub")
+ for candidate in (target, public):
+ if candidate.exists() or candidate.is_symlink():
+ raise BoundaryError(f"{candidate.name} already exists, so nothing was written.")
+ return target
+
+
+def terminal_environment() -> dict:
+ """The environment ssh-keygen must run in to ask its question at the terminal.
+
+ This desktop sets SSH_ASKPASS_REQUIRE=prefer, which makes ssh-keygen open a
+ graphical passphrase dialog even when it has a perfectly good terminal in
+ front of it -- so the first version of this hung, waiting for a prompt that
+ had been drawn on somebody's screen instead. The terminal is supplied
+ deliberately here, so the askpass route is switched off just as deliberately.
+
+ LC_ALL is pinned so the prompts read below are the ones OpenSSH ships.
+ """
+ environment = dict(os.environ)
+ environment["SSH_ASKPASS_REQUIRE"] = "never"
+ environment["LC_ALL"] = "C"
+ for name in ("SSH_ASKPASS", "DISPLAY", "WAYLAND_DISPLAY"):
+ environment.pop(name, None)
+ return environment
+
+
+def type_at_keygen(command: list[str], passphrase: str) -> None:
+ """Run ssh-keygen on a pseudo-terminal and answer its prompts.
+
+ ssh-keygen reads a passphrase through readpassphrase(), which opens
+ /dev/tty: a pipe on standard input is not read at all, which is why this
+ needs a terminal rather than a simpler subprocess call. The passphrase is
+ written to the terminal's master side, exactly as typing it would, and
+ ssh-keygen asks twice, so it is typed twice.
+
+ Nothing about the passphrase is kept. It is not written to disk, does not
+ appear in the command, and is scrubbed out of anything reported back in case
+ a future ssh-keygen ever echoes it.
+ """
+ try:
+ pid, master = pty.fork()
+ except OSError as error:
+ raise BoundaryError("A terminal could not be opened for ssh-keygen.") from error
+
+ if pid == 0:
+ # The child. Nothing may return from here into the parent's code holding
+ # the parent's file descriptors, so a failed exec exits outright.
+ try:
+ os.execvpe(command[0], command, terminal_environment())
+ except OSError:
+ pass
+ os._exit(127)
+
+ typed = 0
+ pending = ""
+ transcript = ""
+ problem = ""
+ deadline = time.monotonic() + KEYGEN_TIMEOUT_SECONDS
+
+ while True:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ problem = "ssh-keygen did not finish."
+ break
+ try:
+ ready, _, _ = select.select([master], [], [], min(remaining, 1.0))
+ except OSError:
+ break
+ if not ready:
+ continue
+ try:
+ chunk = os.read(master, 4096)
+ except OSError:
+ # EIO on Linux: the child closed the terminal, which is how a pty
+ # reports end of output.
+ break
+ if not chunk:
+ break
+
+ text = chunk.decode("utf-8", errors="replace")
+ pending += text
+ transcript += text
+ lowered = pending.lower()
+
+ # Matched on ssh-keygen's whole prompt rather than one word of it: the
+ # passphrase prompt quotes the key's path back, and "overwrite" is a
+ # perfectly legal key name.
+ if "overwrite (y/n)" in lowered:
+ # Unreachable in practice -- an existing key is refused before this
+ # runs -- but answering anything other than "no" here would destroy
+ # a key, so it answers no and stops.
+ os.write(master, b"n\n")
+ problem = "That key already exists, so nothing was written."
+ break
+ if "passphrase is too short" in lowered:
+ problem = (f"ssh-keygen wants a passphrase of at least "
+ f"{MINIMUM_PASSPHRASE} characters.")
+ break
+ if "passphrases do not match" in lowered:
+ problem = "Those passphrases did not match."
+ break
+ if typed < 2 and "passphrase" in lowered and pending.rstrip().endswith(":"):
+ os.write(master, passphrase.encode("utf-8") + b"\n")
+ typed += 1
+ pending = ""
+
+ if problem:
+ try:
+ os.kill(pid, signal.SIGKILL)
+ except OSError:
+ pass
+
+ try:
+ os.close(master)
+ except OSError:
+ pass
+ try:
+ _, status = os.waitpid(pid, 0)
+ except OSError:
+ status = 0
+
+ if problem:
+ raise BoundaryError(problem)
+ if not (os.WIFEXITED(status) and os.WEXITSTATUS(status) == 0):
+ detail = scrubbed(transcript, passphrase)
+ last = [line.strip() for line in detail.splitlines() if line.strip()]
+ raise BoundaryError(last[-1] if last else "ssh-keygen could not create that key.")
+
+
+def scrubbed(text: str, secret: str) -> str:
+ return text.replace(secret, "********") if secret else text
+
+
+def generate(name: str, comment: str, passphrase: str, allow_empty: bool) -> None:
+ """Create an ed25519 key.
+
+ ed25519 and nothing else: it is the key everything current accepts, the
+ choice between it and RSA is not one a settings page should make someone
+ make, and offering a size field for a curve that has one size would be
+ theatre.
+ """
+ if not KEY_COMMENT.match(comment or ""):
+ raise BoundaryError("A comment cannot contain control characters.")
+ if "\n" in passphrase or "\r" in passphrase:
+ raise BoundaryError("A passphrase cannot contain a line break.")
+ if passphrase == "":
+ if not allow_empty:
+ raise BoundaryError(
+ "A passphrase is required. A key with none is usable by anyone "
+ "who reads the file.")
+ elif len(passphrase) < MINIMUM_PASSPHRASE:
+ raise BoundaryError(
+ f"ssh-keygen wants a passphrase of at least {MINIMUM_PASSPHRASE} characters.")
+
+ if shutil.which("ssh-keygen") is None:
+ raise BoundaryError("ssh-keygen is not installed.")
+
+ target = resolve_new_key(name)
+ command = ["ssh-keygen", "-t", "ed25519", "-f", str(target)]
+ if comment:
+ command += ["-C", comment]
+ type_at_keygen(command, passphrase)
+
+ if not target.is_file() or not Path(str(target) + ".pub").is_file():
+ raise BoundaryError("ssh-keygen finished but the key is not there.")
+
+
+def fix_permissions(name: str) -> None:
+ """Make a private key readable only by its owner.
+
+ ssh refuses to use a key other people can read, and says so in a message
+ most people meet for the first time at the worst moment. The path is
+ resolved and compared against the SSH directory first, so a name that is a
+ link to something elsewhere is refused rather than followed -- this changes
+ a file's mode, and that is not a thing to do to a file you have not checked.
+ """
+ if not KEY_NAME.match(name or "") or name in (".", ".."):
+ raise BoundaryError("That is not a key name.")
+ key = resolve_key(str(SSH_DIR / name))
+ try:
+ os.chmod(key, 0o600)
+ except OSError as error:
+ raise BoundaryError("That key's permissions could not be changed.") from error
+
+
def forget_host(host: str) -> None:
"""Drop a host's keys from known_hosts.
@@ -271,13 +512,42 @@ def forget_host(host: str) -> None:
raise BoundaryError(detail[-1] if detail else "That host could not be removed.")
+def read_passphrase() -> str:
+ """The passphrase, from standard input, and only from there.
+
+ One trailing newline is dropped because the caller writes one to end the
+ line; anything else is taken literally, including spaces, because a
+ passphrase is allowed to end in one.
+ """
+ try:
+ raw = sys.stdin.buffer.read().decode("utf-8")
+ except (OSError, UnicodeDecodeError) as error:
+ raise BoundaryError("The passphrase could not be read.") from error
+ if raw.endswith("\n"):
+ raw = raw[:-1]
+ if raw.endswith("\r"):
+ raw = raw[:-1]
+ return raw
+
+
def main(arguments: list[str]) -> int:
try:
if arguments == ["snapshot"]:
print(json.dumps(snapshot(), separators=(",", ":")))
return 0
- if len(arguments) == 2 and arguments[0] == "agent-add":
+ if arguments and arguments[0] == "generate":
+ # The flag exists so a machine-shaped caller can ask for a key with
+ # no passphrase deliberately. Panama's own page never passes it: it
+ # requires a passphrase and validates that both fields match.
+ allow_empty = "--no-passphrase" in arguments[1:]
+ rest = [value for value in arguments[1:] if value != "--no-passphrase"]
+ if len(rest) != 2:
+ raise BoundaryError("Usage: panama-ssh-keys generate NAME COMMENT")
+ generate(rest[0], rest[1], read_passphrase(), allow_empty)
+ elif len(arguments) == 2 and arguments[0] == "fix-permissions":
+ fix_permissions(arguments[1])
+ elif len(arguments) == 2 and arguments[0] == "agent-add":
agent_add(arguments[1])
elif len(arguments) == 2 and arguments[0] == "agent-remove":
agent_remove(arguments[1])
@@ -285,7 +555,8 @@ def main(arguments: list[str]) -> int:
forget_host(arguments[1])
else:
raise BoundaryError(
- "Usage: panama-ssh-keys snapshot | agent-add PATH | agent-remove PATH | "
+ "Usage: panama-ssh-keys snapshot | generate NAME COMMENT | "
+ "fix-permissions NAME | agent-add PATH | agent-remove PATH | "
"forget-host HOST")
except BoundaryError as error:
try:
diff --git a/config/dot/quickshell/services/Permissions.qml b/config/dot/quickshell/services/Permissions.qml
index 2737aa0..f34d3e2 100644
--- a/config/dot/quickshell/services/Permissions.qml
+++ b/config/dot/quickshell/services/Permissions.qml
@@ -1,12 +1,26 @@
pragma Singleton
-// Which applications may use the camera and microphone.
+// What the desktop portal has recorded, for the six subjects it arbitrates.
//
// Backed by xdg-desktop-portal's permission store, which records the answer an
// application got when it asked through the portal. That is the whole of what
// this controls, and the limit belongs on the page rather than in a comment: a
// native binary opens /dev/video0 directly and no desktop setting stands in its
// way. What this covers is Flatpaks and anything else going through the portal.
+//
+// Two shapes of permission, and the difference matters to the page:
+//
+// simple camera, microphone, background. A plain yes or no, so a switch
+// can honour what it shows.
+// revoke-only screencast, remote-desktop. Each grant is a remembered session
+// -- which monitor, which input devices -- and nothing here can
+// rebuild one, so these can be dropped and not switched. The
+// helper has no code path that writes them at all; this service
+// refuses before it gets there, and neither refusal is the only
+// one.
+//
+// location is listed and nothing more. geoclue is absent on this machine, so
+// the table is normally empty and the page leaves the section out entirely.
import Quickshell
import Quickshell.Io
@@ -15,10 +29,16 @@ import QtQuick
Singleton {
id: root
- readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-permissions"
+ readonly property string helperPath: Quickshell.env("PANAMA_PERMISSIONS_HELPER")
+ || Quickshell.shellDir + "/scripts/panama-permissions"
property bool available: false
- property var devices: []
+
+ // { camera: [{ app, allowed, grants, raw }], microphone: [...], ... }
+ // Every table is present even when empty, so the page can say "nothing has
+ // asked" rather than quietly leaving a subject out.
+ property var tables: ({})
+
property bool scanned: false
property string lastError: ""
@@ -26,13 +46,47 @@ Singleton {
// returns its cached value inside the handler that changes its dependency.
readonly property bool busy: query.running || mutation.running
- // Devices something has actually asked for. A device nothing has asked for
- // is still reported, so the page can say so rather than omit it.
- readonly property var recorded: root.devices.filter(
- device => (device.applications ?? []).length > 0)
+ // The tables whose value is a plain yes or no, and the ones that can only be
+ // revoked. Read from the helper so the two never drift apart, with the
+ // helper's own answer as the fallback before the first read lands.
+ property var simpleTables: ["camera", "microphone", "background"]
+ property var revokeOnlyTables: ["screencast", "remote-desktop"]
- readonly property int grantedCount: root.devices.reduce(
- (total, device) => total + (device.applications ?? []).filter(app => app.allowed).length, 0)
+ function rowsFor(table: string): var {
+ const rows = root.tables[table];
+ return Array.isArray(rows) ? rows : [];
+ }
+
+ function countFor(table: string): int {
+ return root.rowsFor(table).length;
+ }
+
+ function isSimple(table: string): bool {
+ return (root.simpleTables ?? []).indexOf(table) >= 0;
+ }
+
+ function isRevokeOnly(table: string): bool {
+ return (root.revokeOnlyTables ?? []).indexOf(table) >= 0;
+ }
+
+ readonly property int grantedCount: {
+ let total = 0;
+ for (const name in root.tables)
+ total += root.rowsFor(name).filter(row => row.allowed === true).length;
+ return total;
+ }
+
+ // The devices half of the store, in the shape the applications list has
+ // always read it in. Kept so that "this app has a privacy rule" keeps
+ // working there without that page having to learn about tables.
+ readonly property var devices: {
+ const known = root.tables;
+ const rows = name => Array.isArray(known[name]) ? known[name] : [];
+ return [
+ { "id": "camera", "label": "Camera", "applications": rows("camera") },
+ { "id": "microphone", "label": "Microphone", "applications": rows("microphone") },
+ ];
+ }
function refresh(): void {
if (query.running)
@@ -45,7 +99,11 @@ Singleton {
try {
const parsed = JSON.parse(text);
root.available = parsed.available === true;
- root.devices = Array.isArray(parsed.devices) ? parsed.devices : [];
+ root.tables = (parsed.tables && typeof parsed.tables === "object") ? parsed.tables : ({});
+ if (Array.isArray(parsed.simpleTables))
+ root.simpleTables = parsed.simpleTables;
+ if (Array.isArray(parsed.revokeOnlyTables))
+ root.revokeOnlyTables = parsed.revokeOnlyTables;
root.lastError = String(parsed.error ?? "");
} catch (error) {
root.lastError = "Could not read the portal's permissions.";
@@ -62,14 +120,23 @@ Singleton {
mutation.running = true;
}
- function setAllowed(device: string, app: string, allowed: bool): void {
- root.run(["set", device, app, allowed ? "allow" : "deny"]);
+ // Only for the tables whose stored value is a plain yes or no. A screencast
+ // grant reaching this is a bug in the caller, and it is refused here rather
+ // than forwarded, so no switch can be wired to something the portal will not
+ // honour.
+ function setPermission(table: string, app: string, allowed: bool): void {
+ if (!root.isSimple(table)) {
+ root.lastError = "That permission describes a whole session, so it can only be revoked.";
+ return;
+ }
+ root.run(["set", table, app, allowed ? "true" : "false"]);
}
// Drops the recorded answer entirely, so the application is asked again the
- // next time it wants the device.
- function forget(device: string, app: string): void {
- root.run(["forget", device, app]);
+ // next time it wants this. Works for every table, including the ones a
+ // switch cannot touch.
+ function revoke(table: string, app: string): void {
+ root.run(["forget", table, app]);
}
Component.onCompleted: root.refresh()
diff --git a/config/dot/quickshell/services/SettingsSearch.qml b/config/dot/quickshell/services/SettingsSearch.qml
index b036ee3..4649af8 100644
--- a/config/dot/quickshell/services/SettingsSearch.qml
+++ b/config/dot/quickshell/services/SettingsSearch.qml
@@ -130,6 +130,12 @@ Singleton {
{ label: "SSH agent", detail: "Which keys are held for this session", page: "ssh-keys" },
{ label: "Known hosts", detail: "Machines this one has connected to before", page: "ssh-keys" },
{ label: "Public key", detail: "Copy the half you paste into a server", page: "ssh-keys" },
+ // The page makes keys now rather than only listing them, so the verbs
+ // people arrive with — make one, unload one, fix the mode ssh refuses —
+ // each have a name to be found by.
+ { label: "Generate an SSH key", detail: "Create an ed25519 key with a passphrase, without opening a terminal", page: "ssh-keys" },
+ { label: "Fix key permissions", detail: "Make a private key readable only by you, which is what ssh insists on", page: "ssh-keys" },
+ { label: "Remove from agent", detail: "Stop a key being offered for the rest of this session", page: "ssh-keys" },
{ label: "Podman", detail: "Running containers, images and volumes", page: "containers" },
{ label: "Container logs", detail: "Follow what a container is printing", page: "containers" },
{ label: "Reclaim container space", detail: "Remove images and volumes nothing uses", page: "containers" },
@@ -176,6 +182,18 @@ Singleton {
{ label: "Screen sharing", detail: "Which applications may capture the screen", page: "privacy" },
{ label: "File history and trash", detail: "What is remembered and when it is cleared", page: "privacy" },
{ label: "Device security", detail: "Secure boot and firmware protections", page: "privacy" },
+ // Privacy stopped handing file history to GNOME and stopped listing two
+ // devices where the portal arbitrates six tables, so the subjects it
+ // now actually owns are findable by their own names. "Application
+ // permissions" appears twice on purpose: the Applications page answers
+ // what a Flatpak's sandbox exposes, this one answers what the portal
+ // recorded, and they are different questions with the same name.
+ { label: "Background apps", detail: "Which applications may keep running after you close them", page: "privacy" },
+ { label: "Screen sharing permission", detail: "Take back a screen-capture grant an application was given", page: "privacy" },
+ { label: "Remote desktop permission", detail: "Take back a grant to control this desktop's pointer and keyboard", page: "privacy" },
+ { label: "Clear recent files", detail: "Empty the list of documents this desktop remembers you opening", page: "privacy" },
+ { label: "Thumbnails", detail: "The cached previews of your pictures and videos, and clearing them", page: "privacy" },
+ { label: "Application permissions", detail: "What the desktop portal has recorded: camera, microphone, screen, background", page: "privacy" },
{ label: "Language", detail: "The system language, applied to programs started afterwards", page: "region" },
{ label: "Regional formats", detail: "How dates, times, and numbers are written", page: "region" },
{ label: "Online accounts", detail: "Sign in to mail, calendar, and contacts", page: "accounts" },
diff --git a/config/dot/quickshell/services/SshKeys.qml b/config/dot/quickshell/services/SshKeys.qml
index 11c66e2..20d5e7b 100644
--- a/config/dot/quickshell/services/SshKeys.qml
+++ b/config/dot/quickshell/services/SshKeys.qml
@@ -2,10 +2,16 @@ pragma Singleton
// SSH keys, the agent holding them, and the hosts this machine has met.
//
-// Nothing here ever sees a private key or a passphrase. Adding an encrypted key
-// makes ssh-add prompt through the system's own askpass, which is where a
-// passphrase belongs -- a settings page collecting one and passing it along
-// would be a worse place for it to live.
+// Nothing here ever sees a private key. A passphrase crosses this service in
+// exactly one place -- creating a key -- and it crosses without ever being
+// stored: it is held only for as long as it takes to write it to the helper's
+// standard input, which is a channel no other process can read, and cleared in
+// the same handler. It is never put in a command, because argv is public to
+// every process on the machine, and never written to a file. The helper types it
+// at ssh-keygen over a terminal it opens for the purpose.
+//
+// Adding an EXISTING encrypted key is different and collects nothing: ssh-add
+// prompts through the system's own askpass, which is where that belongs.
import Quickshell
import Quickshell.Io
@@ -14,7 +20,8 @@ import QtQuick
Singleton {
id: root
- readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-ssh-keys"
+ readonly property string helperPath: Quickshell.env("PANAMA_SSH_KEYS_HELPER")
+ || Quickshell.shellDir + "/scripts/panama-ssh-keys"
property bool available: false
property string directory: ""
@@ -24,10 +31,31 @@ Singleton {
property bool scanned: false
property string lastError: ""
- readonly property bool busy: query.running || mutation.running
+ // Held between the call and the moment the helper starts, and no longer.
+ // Never read back out, never logged, never part of a command line.
+ property string pendingPassphrase: ""
+
+ readonly property bool busy: query.running || mutation.running || generator.running
+
+ // Generating is its own state: it can take a moment, it is the one action
+ // here that creates something, and a page wants to say so specifically
+ // rather than greying out the whole card.
+ readonly property bool generating: generator.running
readonly property int loadedCount: root.keys.filter(key => key.loaded === true).length
+ // Whether removing a key from this machine's agent actually sticks.
+ // gnome-keyring's agent enumerates whatever it finds in ~/.ssh, so a
+ // removal reports success and the key is back a second later. The helper
+ // measures this rather than assuming it, and refuses the removal with its
+ // reason -- which arrives here as lastError, in the helper's own words.
+ readonly property bool durableRemoval: root.agent?.durableRemoval === true
+ readonly property string agentKind: String(root.agent?.kind ?? "")
+
+ // Set for a moment after a public key reaches the clipboard, so the page can
+ // say what happened without having to know how long a clipboard lasts.
+ property string copiedKey: ""
+
// Keys readable by anyone but their owner. ssh refuses to use these, so a
// page that stayed quiet about it would leave someone wondering why a key
// that plainly exists is never offered.
@@ -69,6 +97,28 @@ Singleton {
// is allowed a long time before it is considered stuck.
function addToAgent(path: string): void { root.run(["agent-add", path]); }
+ // Unloading a key. On an agent where that does not stick the helper refuses
+ // and says why, and the page shows that sentence rather than a shorter one
+ // of its own -- the reason is the useful part.
+ function removeFromAgent(path: string): void { root.run(["agent-remove", path]); }
+
+ // ed25519, always. The passphrase goes down the helper's standard input and
+ // nowhere else; see the note at the top of this file.
+ function generate(name: string, comment: string, passphrase: string): void {
+ if (generator.running)
+ return;
+ root.lastError = "";
+ root.pendingPassphrase = passphrase;
+ generator.command = [root.helperPath, "generate", name, comment];
+ generator.stdinEnabled = true;
+ generator.running = true;
+ }
+
+ // 0600, so ssh will use the key at all. Takes the key's name rather than a
+ // path: the helper resolves it inside ~/.ssh and refuses anything that
+ // resolves elsewhere, which is not a check to hand to the caller.
+ function fixPermissions(name: string): void { root.run(["fix-permissions", name]); }
+
// The public half, onto the clipboard. Safe to copy by definition -- it is
// the thing you paste into a server. The path arrives as $1 rather than
// being spliced into shell source, so a name with a space or a quote in it
@@ -76,6 +126,7 @@ Singleton {
function copyPublicKey(publicPath: string): void {
if (publicPath === "" || copier.running)
return;
+ root.copiedKey = publicPath;
copier.command = ["sh", "-c", 'exec wl-copy < "$1"', "qs-ssh-keys", publicPath];
copier.running = true;
}
@@ -83,7 +134,39 @@ Singleton {
Component.onCompleted: root.refresh()
- Process { id: copier }
+ Process {
+ id: copier
+ onExited: exitCode => { if (exitCode !== 0) root.copiedKey = ""; }
+ }
+
+ // The clipboard notice is transient, and says so by disappearing.
+ Timer {
+ running: root.copiedKey !== ""
+ interval: 12000
+ onTriggered: root.copiedKey = ""
+ }
+
+ // Its own Process because it is the only one with anything on stdin, and
+ // because the passphrase handover has to happen in onStarted -- there is
+ // nothing to write to before then.
+ Process {
+ id: generator
+ stdinEnabled: true
+ onStarted: {
+ generator.write(root.pendingPassphrase + "\n");
+ // Held for as long as it takes to hand over, and no longer.
+ root.pendingPassphrase = "";
+ // Closing stdin is what tells the helper the passphrase is complete.
+ generator.stdinEnabled = false;
+ }
+ // The helper answers with the fresh snapshot plus whatever went wrong,
+ // so a created key lands on the page without a second read.
+ stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
+ stderr: StdioCollector {
+ onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
+ }
+ onExited: root.pendingPassphrase = ""
+ }
Process {
id: query
diff --git a/config/dot/quickshell/services/Traces.qml b/config/dot/quickshell/services/Traces.qml
new file mode 100644
index 0000000..4d98378
--- /dev/null
+++ b/config/dot/quickshell/services/Traces.qml
@@ -0,0 +1,114 @@
+pragma Singleton
+
+// What this desktop remembers about what you opened.
+//
+// Two traces -- the recent-files list and the thumbnail cache -- both of them a
+// normal and useful part of a desktop rather than a problem. This measures them
+// when asked and clears them when asked, and does neither on its own.
+//
+// Nothing here measures on startup. Walking a thumbnail cache costs real time
+// on a machine that has browsed a large picture library, and a settings page
+// that has not been opened has no business spending it. `measured` says whether
+// there is an answer yet, so the card can show its own state honestly instead of
+// showing a confident zero.
+//
+// Trash is deliberately absent. It is measured and emptied by Disks, and one
+// trash implementation is the right number to have.
+
+import Quickshell
+import Quickshell.Io
+import QtQuick
+
+Singleton {
+ id: root
+
+ readonly property string helperPath: Quickshell.env("PANAMA_PRIVACY_HELPER")
+ || Quickshell.shellDir + "/scripts/panama-privacy"
+
+ // real rather than int: a thumbnail cache on a machine with a large picture
+ // library goes past what a 32-bit int holds, and a byte count that wrapped
+ // negative is worse than no byte count.
+ property real recentsBytes: 0
+ property int recentsEntries: 0
+ property string recentsPath: ""
+ property bool recentsPresent: false
+
+ property real thumbnailsBytes: 0
+ property string thumbnailsPath: ""
+ property bool thumbnailsPresent: false
+
+ // False when the walk ran out of time, which makes the byte count a floor
+ // rather than an answer.
+ property bool thumbnailsComplete: true
+
+ // Whether there is an answer at all yet. Distinct from thumbnailsComplete,
+ // which is about the quality of an answer that exists.
+ property bool measured: false
+
+ property string lastError: ""
+
+ readonly property bool busy: reader.running || mutation.running
+
+ function measure(): void {
+ if (reader.running)
+ return;
+ reader.command = [root.helperPath, "traces"];
+ reader.running = true;
+ }
+
+ function absorb(text: string): void {
+ try {
+ const parsed = JSON.parse(text);
+ const recents = parsed.recents ?? ({});
+ const thumbnails = parsed.thumbnails ?? ({});
+ root.recentsBytes = Number(recents.bytes ?? 0);
+ root.recentsEntries = Number(recents.entries ?? 0);
+ root.recentsPath = String(recents.path ?? "");
+ root.recentsPresent = recents.present === true;
+ root.thumbnailsBytes = Number(thumbnails.bytes ?? 0);
+ root.thumbnailsPath = String(thumbnails.path ?? "");
+ root.thumbnailsPresent = thumbnails.present === true;
+ root.thumbnailsComplete = thumbnails.measured !== false;
+ root.lastError = String(parsed.error ?? "");
+ } catch (error) {
+ root.lastError = "Could not read what the desktop has remembered.";
+ console.warn("Traces: could not parse helper output:", error);
+ }
+ root.measured = true;
+ }
+
+ function run(verb: string): void {
+ if (mutation.running)
+ return;
+ root.lastError = "";
+ mutation.command = [root.helperPath, verb];
+ mutation.running = true;
+ }
+
+ // The list is emptied, never deleted: GTK recreates the file the moment
+ // anything opens a file anyway, and an empty valid document takes effect in
+ // every running application immediately.
+ function clearRecents(): void { root.run("clear-recents"); }
+
+ // The folder stays; what is inside it goes. The helper refuses to follow a
+ // link out of it.
+ function clearThumbnails(): void { root.run("clear-thumbnails"); }
+
+ Process {
+ id: reader
+ stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
+ stderr: StdioCollector {
+ onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
+ }
+ }
+
+ // Both clears answer with a freshly measured snapshot, so the card updates
+ // without a second read.
+ Process {
+ id: mutation
+ stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
+ stderr: StdioCollector {
+ onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
+ }
+ }
+}
diff --git a/config/local/share/vicinae/scripts/settings-privacy b/config/local/share/vicinae/scripts/settings-privacy
index 49aa7dc..7265527 100755
--- a/config/local/share/vicinae/scripts/settings-privacy
+++ b/config/local/share/vicinae/scripts/settings-privacy
@@ -5,6 +5,6 @@
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open Privacy in Settings.
-# @vicinae.keywords ["settings", "saved passwords", "camera and microphone", "screen sharing", "file history and trash", "device security"]
+# @vicinae.keywords ["settings", "saved passwords", "camera and microphone", "screen sharing", "file history and trash", "device security", "background apps", "screen sharing permission", "remote desktop permission", "clear recent files", "thumbnails", "application permissions"]
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page privacy
diff --git a/config/local/share/vicinae/scripts/settings-ssh-keys b/config/local/share/vicinae/scripts/settings-ssh-keys
index acad41c..7b78fdd 100755
--- a/config/local/share/vicinae/scripts/settings-ssh-keys
+++ b/config/local/share/vicinae/scripts/settings-ssh-keys
@@ -5,6 +5,6 @@
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open SSH Keys in Settings.
-# @vicinae.keywords ["settings", "ssh keys", "ssh agent", "known hosts", "public key"]
+# @vicinae.keywords ["settings", "ssh keys", "ssh agent", "known hosts", "public key", "generate an ssh key", "fix key permissions", "remove from agent"]
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page ssh-keys
diff --git a/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md b/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md
index be945f2..5b3628f 100644
--- a/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md
+++ b/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md
@@ -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 `` 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.
diff --git a/docs/superpowers/specs/2026-08-24-privacy-security-redesign.md b/docs/superpowers/specs/2026-08-24-privacy-security-redesign.md
new file mode 100644
index 0000000..55d0205
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-24-privacy-security-redesign.md
@@ -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.
diff --git a/tests/quickshell/gnome-handoff-contract b/tests/quickshell/gnome-handoff-contract
index 032dd38..2b8e0ef 100755
--- a/tests/quickshell/gnome-handoff-contract
+++ b/tests/quickshell/gnome-handoff-contract
@@ -47,6 +47,14 @@ fail() {
# "online-accounts" is listed because Panama has an Online Accounts page -- but
# adding an account still has to go through GOA's own dialog, so that one
# exception is named explicitly below.
+# "privacy" joined the list when Privacy & Security stopped being a page that
+# read the portal and pointed at GNOME for everything else. It now clears the
+# recent-files list and the thumbnail cache itself, empties the trash through
+# Storage's own cleanable, and revokes portal grants for six tables rather than
+# three devices. The card that used to carry the door -- headed "Owned by
+# Fedora", explaining that GNOME's file-history switches would not take effect
+# in a Hyprland session anyway -- is gone, because the switches it was
+# apologizing for are now buttons that work.
declare -A OWNED=(
[network]=connectivity
[wifi]=connectivity
@@ -55,6 +63,7 @@ declare -A OWNED=(
[sharing]=sharing
[users]=users
[system\ users]=users
+ [privacy]=privacy
)
# Handoffs that are correct despite naming an owned panel, with the reason.
@@ -109,6 +118,15 @@ done < <(grep -rno --include='*.qml' -E 'openGnomePanel\("[^"]*"(, *"[^"]*")?\)'
(( checked > 0 )) || fail 'no handoffs were examined, so this proves nothing'
+# The inverse, for the page that just stopped handing anything over. The loop
+# above can only fail on a door that exists; said this way it also fails if the
+# door comes back under a panel name nobody thought to list.
+if grep -q 'openGnomePanel' "$settings_dir/PrivacyPage.qml"; then
+ printf 'gnome handoff contract: PrivacyPage still opens a GNOME panel:\n' >&2
+ grep -n 'openGnomePanel' "$settings_dir/PrivacyPage.qml" >&2
+ violations=$((violations + 1))
+fi
+
if (( violations > 0 )); then
printf 'Each of these sends someone to GNOME for a page this app already has.\n' >&2
exit 1
diff --git a/tests/quickshell/lock-screen-settings-contract b/tests/quickshell/lock-screen-settings-contract
index f7992f4..a9a2072 100755
--- a/tests/quickshell/lock-screen-settings-contract
+++ b/tests/quickshell/lock-screen-settings-contract
@@ -46,6 +46,24 @@ for key in lockBackgroundMode lockBlurLevel lockShowClock lockShowDate lockShowU
|| fail "$key was duplicated onto Power or Privacy"
done
+# ── One home for the lock timings, and a signpost where the copy used to be ──
+#
+# Privacy carried a whole second "Screen lock" card: the same idle timings, the
+# same preferences, bound twice. Two sliders writing one value is not a
+# convenience -- it is a page where the number you are looking at may not be the
+# number you last set. The card is gone, and what replaced it is a pointer,
+# because deleting a duplicate without saying where the original lives just
+# moves the confusion.
+! rg -Fq 'title: "Screen lock"' "$privacy" \
+ || fail 'the duplicated Screen lock card is back on the Privacy page'
+for key in lockMinutes lockMinutesBattery lockOnSleep; do
+ rg -Fq "setting: \"$key\"" "$power" || fail "Power does not own $key"
+ ! rg -Fq "setting: \"$key\"" "$privacy" \
+ || fail "$key is bound on Privacy as well as Power, so one preference has two controls"
+done
+rg -Fq 'ShellState.openSettings("power")' "$privacy" \
+ || fail 'Privacy dropped the lock card without pointing anywhere, so the settings look deleted'
+
# The lock screen is the third card of the Background tab, after the still and
# video wallpaper cards. It belongs there because it is a picture of the
# desktop: the background is what it blurs, and the card above is what it
diff --git a/tests/quickshell/permissions-contract b/tests/quickshell/permissions-contract
index 57479cc..7bee8dc 100755
--- a/tests/quickshell/permissions-contract
+++ b/tests/quickshell/permissions-contract
@@ -8,19 +8,29 @@
# /dev/video0 directly, and a settings page implying otherwise is worse
# than one that says nothing -- so the limit is stated on the page, not
# buried in a comment.
-# 2. A device nothing has asked for is reported empty, not omitted. "No
+# 2. A table nothing has asked for is reported empty, not omitted. "No
# application uses your microphone" and a page that quietly leaves the
# microphone out look identical and mean very different things.
# 3. Absence is not failure. The store answers "No entry for microphone" for a
-# device nobody has requested; treating that as an error would make the
+# table nobody has requested; treating that as an error would make the
# whole page fail because one device is unused.
# 4. Anything that is not an explicit "yes" is withheld. Guessing generously
# about a camera is the wrong way to be wrong.
# 5. A refusal states its reason.
+# 6. THE NEW ONE. screencast and remote-desktop do not hold yes/no. Their
+# permissions are structured GVariants -- which monitors, whether the
+# pointer is included, how long the grant lasts -- and there is no honest
+# way to reconstruct one from a switch. So those two tables are
+# revoke-only: DeletePermission exists for them and SetPermission does not,
+# anywhere, because a toggle that writes a plausible-looking variant would
+# silently rewrite a grant the user never described.
#
-# The write path is exercised against an application id that does not exist, so
-# no real application's camera access is changed. What is on this machine --
-# OBS Studio and GNOME Snapshot -- is read, never written.
+# SAFETY: this file makes NO live portal writes. The earlier version set and
+# cleared a camera permission on the real permission store under a probe
+# application id and put it back afterwards; the write path is now exercised
+# against a recording `busctl` stub under `env -i`, with the session bus
+# address pointed at a socket that does not exist. Nothing here can reach the
+# store this desktop is actually using.
set -uo pipefail
@@ -28,8 +38,7 @@ repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
helper="$repo_dir/config/dot/quickshell/scripts/panama-permissions"
service="$repo_dir/config/dot/quickshell/services/Permissions.qml"
page="$repo_dir/config/dot/quickshell/modules/settings/PrivacyPage.qml"
-
-probe="org.panama.ContractProbe"
+qml_dir="$repo_dir/config/dot/quickshell"
fail() {
printf 'permissions contract: %s\n' "$1" >&2
@@ -40,75 +49,416 @@ for path in "$helper" "$service" "$page"; do
[[ -r "$path" ]] || fail "missing $path"
done
[[ -x "$helper" ]] || fail 'panama-permissions is not executable'
+command -v jq >/dev/null 2>&1 || { printf 'permissions contract: SKIP (no jq)\n'; exit 0; }
-field() { python3 -c "import json,sys; print(json.load(sys.stdin)$1)"; }
-
-state="$("$helper" snapshot)" || fail 'snapshot failed'
-
-if [[ "$(printf '%s' "$state" | field "['available']")" != "True" ]]; then
- printf 'permissions contract: skipped (the portal permission store is not running)\n'
- exit 0
-fi
+work="$(mktemp -d /tmp/panama-permissions.XXXXXX)"
+trap 'rm -rf "$work"' EXIT
# ── 1. The page states the limit ────────────────────────────────────────────
grep -q 'directly' "$page" \
|| fail 'the page does not say that programs outside the portal reach these devices anyway'
-# ── 2 & 3. Unused devices are present and empty, not an error ───────────────
+# ── 6a. No Set path for the structured tables, by AST ────────────────────────
+#
+# Read from the syntax tree rather than by grepping for the word, because the
+# thing being pinned is reachability: which table names can arrive at the one
+# call that writes. A second settable list, or a settable list that quietly
+# grew "screencast", is exactly the change this has to catch.
-printf '%s' "$state" | python3 -c "
-import json, sys
-state = json.load(sys.stdin)
-if state['error']:
- raise SystemExit(f\"snapshot reported an error: {state['error']}\")
-names = [d['id'] for d in state['devices']]
-for required in ('camera', 'microphone', 'speakers'):
- if required not in names:
- raise SystemExit(f'{required} is missing from the snapshot entirely')
-" || fail 'a device with no recorded application was dropped or reported as an error'
+python3 - "$helper" <<'PY' || fail 'the helper can write a permission for a table whose values it cannot construct'
+import ast
+import importlib.machinery
+import importlib.util
+import sys
-# ── 4 & 5. The write path, on an application that does not exist ────────────
+path = sys.argv[1]
+source = open(path, encoding="utf-8").read()
+tree = ast.parse(source)
-before="$(printf '%s' "$state" | field "['devices']")"
+# Imported rather than pattern-matched, so the lists are read at their real
+# values. They are derived from a table registry rather than typed out, and a
+# contract that only understood one spelling of that would be pinning the
+# spelling. Importing is safe: the module does its work under __main__.
+loader = importlib.machinery.SourceFileLoader("panama_permissions", path)
+module = importlib.util.module_from_spec(importlib.util.spec_from_loader(loader.name, loader))
+# Registered before it runs: a dataclass declared inside it looks its own module
+# up by name while the decorator is running.
+sys.modules[loader.name] = module
+loader.exec_module(module)
-denied="$("$helper" set camera "$probe" deny)" || fail 'set deny failed'
-reason="$(printf '%s' "$denied" | field "['error']")"
-[[ -z "$reason" ]] || fail "denying refused a valid write: $reason"
-printf '%s' "$denied" | python3 -c "
-import json, sys
-for device in json.load(sys.stdin)['devices']:
- for app in device['applications']:
- if app['app'] == '$probe':
- if app['allowed']:
- raise SystemExit('a denied application was reported as allowed')
- raise SystemExit(0)
-raise SystemExit('the denied application was not written at all')
-" || fail 'deny did not take effect -- the write path is not doing anything'
+structured = {"screencast", "remote-desktop"}
-allowed="$("$helper" set camera "$probe" allow)" || fail 'set allow failed'
-printf '%s' "$allowed" | python3 -c "
-import json, sys
-for device in json.load(sys.stdin)['devices']:
- for app in device['applications']:
- if app['app'] == '$probe' and app['allowed']:
- raise SystemExit(0)
-raise SystemExit('allow did not take effect')
-" || fail 'allow did not take effect'
-# Refusals name their reason rather than merely failing.
-reason="$(printf '%s' "$("$helper" set camera "$probe" maybe)" | field "['error']")"
-[[ "$reason" == *"allow or deny"* ]] \
- || fail "an invalid decision was not refused with a reason (got: $reason)"
+def as_names(value):
+ if isinstance(value, dict):
+ value = list(value)
+ if isinstance(value, (list, tuple, set, frozenset)):
+ names = [item for item in value if isinstance(item, str)]
+ return set(names) if len(names) == len(list(value)) else set()
+ return set()
-reason="$(printf '%s' "$("$helper" set nonsense "$probe" allow)" | field "['error']")"
-[[ -n "$reason" ]] || fail 'an unknown device was accepted'
-# ── Put it back ────────────────────────────────────────────────────────────
+collections = {name: as_names(getattr(module, name))
+ for name in dir(module) if not name.startswith("_")}
+collections = {name: value for name, value in collections.items() if value}
-"$helper" forget camera "$probe" >/dev/null || fail 'forget failed'
-after="$("$helper" snapshot | field "['devices']")"
-[[ "$before" == "$after" ]] \
- || fail 'the contract changed recorded permissions and did not restore them'
+known = {name for name, value in collections.items() if structured <= value}
+if not known:
+ raise SystemExit("nothing in the helper names screencast and remote-desktop as tables, "
+ "so it does not know they exist")
-printf 'permissions contract: ok\n'
+# Exactly one place writes. More than one is two policies, and the second one
+# is the one that falls behind.
+writers = []
+deleters = []
+for node in ast.walk(tree):
+ if isinstance(node, ast.FunctionDef):
+ literals = [inner.value for inner in ast.walk(node)
+ if isinstance(inner, ast.Constant) and isinstance(inner.value, str)]
+ if "SetPermission" in literals:
+ writers.append(node)
+ if "DeletePermission" in literals:
+ deleters.append(node)
+
+if len(writers) != 1:
+ raise SystemExit(f"expected exactly one function calling SetPermission, found "
+ f"{[node.name for node in writers]}")
+if not deleters:
+ raise SystemExit("nothing calls DeletePermission, so a grant can never be revoked")
+
+# One call site, not merely one function. A second Set inside the same function,
+# reached by a different branch, would satisfy the count above and defeat the
+# membership check below it.
+call_sites = sum(1 for node in ast.walk(tree)
+ if isinstance(node, ast.Constant) and node.value == "SetPermission")
+if call_sites != 1:
+ raise SystemExit(f"SetPermission is named {call_sites} times; there is one honest "
+ "place to write a permission and it is not two")
+
+writer = writers[0]
+writer_names = {inner.id for inner in ast.walk(writer) if isinstance(inner, ast.Name)}
+
+# The gate: a named list of table names, consulted by the one function that
+# writes. A chain of conditions would do the same job today and grow an
+# exception later; a list membership is one place to look.
+gate = {name for name in writer_names & set(collections) if "camera" in collections[name]}
+if not gate:
+ raise SystemExit(f"{writer.name} writes without consulting any list of the tables that "
+ "MAY be written, so refusing screencast is a runtime accident rather "
+ "than a rule")
+
+# The way this breaks is not by deleting the list; it is by somebody adding
+# "screencast" to it because the page wanted a switch there.
+for name in gate:
+ if structured & collections[name]:
+ raise SystemExit(f"{name}, which {writer.name} consults before writing, lists "
+ f"{sorted(structured & collections[name])} alongside the tables "
+ "that carry a plain yes/no")
+
+writer_literals = {inner.value for inner in ast.walk(writer)
+ if isinstance(inner, ast.Constant) and isinstance(inner.value, str)}
+leaked = structured & writer_literals
+if leaked:
+ raise SystemExit(f"{writer.name} mentions {sorted(leaked)} by name, next to the one call "
+ "that writes a permission")
+PY
+
+# ── 6b. Nothing in the shell asks to set a structured table ─────────────────
+#
+# The rule said from the QML side: no page and no service may hand a
+# revoke-only table to the write path, whatever the helper would do with it.
+offenders="$(grep -rn --include='*.qml' -E 'setPermission\([^)]*(screencast|remote-desktop)' "$qml_dir" || true)"
+[[ -z "$offenders" ]] \
+ || fail "something offers to set a permission it cannot construct: $offenders"
+
+grep -q 'revoke' "$service" \
+ || fail 'the Permissions service has no revoke path, so a structured grant is permanent'
+grep -qE 'revokeOnlyTables|revokeOnly' "$service" \
+ || fail 'the service does not name which tables are revoke-only, so the page has to guess'
+grep -qE 'simpleTables|simpleValued' "$service" \
+ || fail 'the service does not name which tables carry a plain yes/no'
+grep -q 'tables' "$service" \
+ || fail 'the service does not expose the per-table model the page is built from'
+
+# The Applications page reads the camera/microphone half of this service in the
+# older `devices` shape. Generalizing the model to six tables kept that view
+# rather than rewriting a second page's bindings, and something has to notice if
+# it disappears -- the symptom there is an empty list, not an error.
+grep -q 'property var devices' "$service" \
+ || fail 'the devices view is gone; ApplicationsPage reads it and would quietly show nothing'
+consumers="$(grep -rln --include='*.qml' 'Permissions\.devices' "$qml_dir" || true)"
+[[ -n "$consumers" ]] \
+ || fail 'nothing reads Permissions.devices any more, so the compatibility view is dead weight'
+
+# ── The hermetic bus ────────────────────────────────────────────────────────
+#
+# A recording `busctl` first on PATH, answering from a fixture. The proof that
+# it is the one being called is the fixture's own application ids coming back
+# out of `snapshot` -- asserted before anything that writes runs.
+
+mkdir -p "$work/bin"
+export PANAMA_PERMISSIONS_CALL_LOG="$work/calls"
+: >"$PANAMA_PERMISSIONS_CALL_LOG"
+
+cat >"$work/bin/busctl" <<'STUB'
+#!/usr/bin/env bash
+# A permission store that exists only inside this contract.
+#
+# The method and its arguments are read positionally rather than by pattern, so
+# `List s screencast` and `Lookup ss screencast ` answer with the shapes
+# the real store answers with -- an array of ids for one, a dictionary of
+# applications for the other. Answering both with the same shape would let a
+# helper that never lists a table pass anyway.
+printf '%s\n' "$*" >>"$PANAMA_PERMISSIONS_CALL_LOG"
+
+method=""
+rest=()
+collecting=0
+for argument in "$@"; do
+ if (( collecting )); then
+ rest+=("$argument")
+ continue
+ fi
+ case "$argument" in
+ List|Lookup|SetPermission|DeletePermission|GetPermission)
+ method="$argument"
+ collecting=1 ;;
+ esac
+done
+
+if [[ -z "$method" ]]; then
+ # `busctl --user list`, which is how the helper asks whether the store is
+ # running at all.
+ printf 'org.freedesktop.impl.portal.PermissionStore 1234 - - - -\n'
+ exit 0
+fi
+
+table="${rest[1]:-}"
+entry="${rest[2]:-}"
+
+ids() { printf '{"type":"as","data":[[%s]]}\n' "$1"; }
+row() { printf '{"type":"a{sas}v","data":[%s,{"type":"s","data":""}]}\n' "$1"; }
+absent() { printf 'No entry for %s\n' "$1" >&2; exit 1; }
+
+case "$method" in
+ SetPermission|DeletePermission)
+ printf '{"type":"","data":[]}\n'
+ exit 0 ;;
+ List)
+ case "$table" in
+ # Two remembered sessions for one application, which is the normal
+ # case: the page asks one question and the store holds four answers.
+ # The third id is deliberately not an id -- a store that handed back
+ # something path-shaped must not have it fed straight back in.
+ screencast) ids '"session-a","session-b","../../etc/passwd"' ;;
+ remote-desktop) ids '"session-r"' ;;
+ background) ids '"background"' ;;
+ location) ids '' ;;
+ *) ids '' ;;
+ esac
+ exit 0 ;;
+ Lookup)
+ case "$table/$entry" in
+ devices/camera)
+ row '{"org.panama.FixtureCam":["yes"],"org.panama.FixtureBlocked":["no"]}' ;;
+ devices/microphone)
+ # Nothing has ever asked. The store says so by having no row.
+ absent microphone ;;
+ screencast/session-a|screencast/session-b)
+ row '{"org.panama.FixtureCast":["1","screen","0"]}' ;;
+ remote-desktop/session-r)
+ row '{"org.panama.FixtureRemote":["1","keyboard,pointer","0"]}' ;;
+ background/background)
+ row '{"org.panama.FixtureBackground":["yes"],"org.panama.FixtureQuiet":["no"]}' ;;
+ *)
+ absent "${entry:-$table}" ;;
+ esac
+ exit 0 ;;
+esac
+printf '{"type":"","data":[]}\n'
+STUB
+chmod +x "$work/bin/busctl"
+
+runh() {
+ env -i \
+ PATH="$work/bin:/usr/bin:/bin" \
+ HOME="$work/home" \
+ XDG_RUNTIME_DIR="$work/run" \
+ DBUS_SESSION_BUS_ADDRESS="unix:path=$work/no-such-bus" \
+ PANAMA_PERMISSIONS_CALL_LOG="$PANAMA_PERMISSIONS_CALL_LOG" \
+ LANG=C LC_ALL=C \
+ "$helper" "$@"
+}
+mkdir -p "$work/home" "$work/run"
+
+# The helper has to find busctl on PATH for the stub to mean anything.
+grep -qE '/usr/bin/busctl|/bin/busctl' "$helper" \
+ && fail 'the helper names an absolute busctl, so it cannot be pointed at a fixture'
+
+resolved="$(env -i PATH="$work/bin:/usr/bin:/bin" command -v busctl)"
+[[ "$resolved" == "$work/bin/busctl" ]] \
+ || fail "busctl resolves to $resolved, not the stub; refusing to run anything that writes"
+
+calls() { cat "$PANAMA_PERMISSIONS_CALL_LOG"; }
+wrote() { grep -c 'SetPermission' "$PANAMA_PERMISSIONS_CALL_LOG"; }
+deleted() { grep -c 'DeletePermission' "$PANAMA_PERMISSIONS_CALL_LOG"; }
+
+state="$(runh snapshot)" || fail 'snapshot failed against the fixture bus'
+
+# ── 2, 3 & 4. The shape, and the fixture proving this ran against the stub ──
+
+jq -e '.available == true and (.tables | type == "object") and .error == ""' <<<"$state" >/dev/null \
+ || fail "snapshot is not the tables shape the page is built from: $state"
+
+for table in camera microphone screencast remote-desktop background; do
+ jq -e --arg t "$table" '.tables | has($t)' <<<"$state" >/dev/null \
+ || fail "the $table table is missing from the snapshot entirely"
+ jq -e --arg t "$table" '[.tables[$t][] | has("app") and has("allowed")] | all' <<<"$state" >/dev/null \
+ || fail "a $table row is not { app, allowed }"
+ # `grants` is how a folded row says how many stored answers are behind it,
+ # and `raw` is the store's own value -- the page explains a structured grant
+ # rather than reducing it to a switch, and cannot do that from a boolean.
+ jq -e --arg t "$table" '[.tables[$t][] | has("grants") and has("raw")] | all' <<<"$state" >/dev/null \
+ || fail "a $table row carries no grant count, so a folded row cannot say what it folded"
+done
+
+# The tables the model dropped. "speakers" was in the devices list because the
+# portal has an entry for it, not because anything ever asks: no portal backend
+# arbitrates speaker access, so the row was a permanent "nothing has asked" for
+# a question nobody is asking. Listing it made the page look like it covered
+# more than it does.
+jq -e '.tables | has("speakers") | not' <<<"$state" >/dev/null \
+ || fail 'speakers is back in the permissions model, where nothing ever asks'
+
+# The proof of the fixture: these ids exist nowhere but in this file.
+jq -e '[.tables.camera[] | select(.app == "org.panama.FixtureCam" and .allowed == true)] | length == 1' \
+ <<<"$state" >/dev/null \
+ || fail "snapshot did not read the fixture's camera table; refusing to go on: $state"
+jq -e '[.tables.camera[] | select(.app == "org.panama.FixtureBlocked" and .allowed == false)] | length == 1' \
+ <<<"$state" >/dev/null \
+ || fail 'a permission that is not an explicit yes was reported as allowed'
+
+# Rule 3, from the store's own words: "No entry for microphone" is a table
+# nobody has asked about, not a failure of the page.
+jq -e '.tables.microphone == []' <<<"$state" >/dev/null \
+ || fail "an unused table was not reported as empty: $(jq -c .tables.microphone <<<"$state")"
+jq -e '.error == ""' <<<"$state" >/dev/null \
+ || fail 'an unused table was reported as an error, which fails the whole page over one unused device'
+
+jq -e '[.tables["remote-desktop"][] | select(.app == "org.panama.FixtureRemote")] | length == 1' \
+ <<<"$state" >/dev/null \
+ || fail 'a structured grant was dropped rather than listed for revoking'
+
+# One application, two remembered screencast sessions, ONE row. The page asks
+# "may this application share your screen", which is one question; a store that
+# holds four answers to it must not become four rows.
+jq -e '[.tables.screencast[] | select(.app == "org.panama.FixtureCast")] | length == 1' \
+ <<<"$state" >/dev/null \
+ || fail "one application's several remembered sessions became several rows: $(jq -c .tables.screencast <<<"$state")"
+
+# An id the store handed back that is not an id shape is dropped, not fed
+# straight back into a Lookup.
+grep -qF '../../etc/passwd' "$PANAMA_PERMISSIONS_CALL_LOG" \
+ && fail 'a path-shaped entry id from the store was passed back into the permission store'
+
+# The two lists the page builds itself from, and they do not overlap.
+jq -e '(.simpleTables | index("screencast")) == null
+ and (.simpleTables | index("remote-desktop")) == null
+ and (.revokeOnlyTables | index("screencast")) != null
+ and (.revokeOnlyTables | index("remote-desktop")) != null' <<<"$state" >/dev/null \
+ || fail "the page is told the wrong tables are toggleable: $(jq -c '{simpleTables,revokeOnlyTables}' <<<"$state")"
+
+# ── 6c. The write path, at runtime ──────────────────────────────────────────
+#
+# Which vocabulary the helper takes is read from the helper rather than
+# assumed, so this pins the rule and not the spelling.
+
+probe="org.panama.ContractProbe"
+verb=""
+for candidate in true allow; do
+ : >"$PANAMA_PERMISSIONS_CALL_LOG"
+ if [[ "$(runh set camera "$probe" "$candidate" | jq -r '.error')" == "" ]]; then
+ verb="$candidate"
+ break
+ fi
+done
+[[ -n "$verb" ]] || fail 'the helper accepted neither `set camera APP true` nor `set camera APP allow`'
+(( "$(wrote)" >= 1 )) \
+ || fail "setting a simple table never reached SetPermission: $(calls)"
+
+# Background is the other toggleable one, and the one people actually come to
+# this page for.
+: >"$PANAMA_PERMISSIONS_CALL_LOG"
+reason="$(runh set background "$probe" "$verb" | jq -r '.error')"
+[[ -z "$reason" ]] || fail "the background table refused a plain yes/no write: $reason"
+(( "$(wrote)" >= 1 )) || fail "setting background never reached SetPermission: $(calls)"
+
+# The applications the fixture store actually holds a grant for -- forgetting
+# something nobody granted is a different case, checked below.
+declare -A GRANT_HOLDER=(
+ [screencast]=org.panama.FixtureCast
+ ["remote-desktop"]=org.panama.FixtureRemote
+)
+
+for table in screencast remote-desktop; do
+ holder="${GRANT_HOLDER[$table]}"
+
+ : >"$PANAMA_PERMISSIONS_CALL_LOG"
+ reason="$(runh set "$table" "$holder" "$verb" | jq -r '.error')"
+ [[ -n "$reason" ]] \
+ || fail "setting $table was accepted, but its value cannot be honestly constructed"
+ (( "$(wrote)" == 0 )) \
+ || fail "setting $table was refused in words but still wrote to the store: $(calls)"
+
+ # Revoking, which is the one thing these tables do support.
+ : >"$PANAMA_PERMISSIONS_CALL_LOG"
+ reason="$(runh forget "$table" "$holder" | jq -r '.error')"
+ [[ -z "$reason" ]] || fail "revoking a $table grant was refused: $reason"
+ (( "$(deleted)" >= 1 )) \
+ || fail "revoking $table never reached DeletePermission: $(calls)"
+done
+
+# Every remembered session, not the first one. An application with two stored
+# screencast grants whose row still says "allowed" after Revoke is the failure
+# this catches.
+: >"$PANAMA_PERMISSIONS_CALL_LOG"
+runh forget screencast org.panama.FixtureCast >/dev/null
+(( "$(deleted)" == 2 )) \
+ || fail "revoking dropped $(deleted) of the fixture's two remembered screencast sessions"
+
+# Forgetting something nobody granted says so rather than reporting success.
+: >"$PANAMA_PERMISSIONS_CALL_LOG"
+reason="$(runh forget screencast "$probe" | jq -r '.error')"
+[[ -n "$reason" ]] || fail 'revoking a grant that does not exist reported success'
+(( "$(deleted)" == 0 )) || fail 'revoking a grant that does not exist still deleted something'
+
+# ── 5. Refusals name their reason, and reach nothing ────────────────────────
+
+: >"$PANAMA_PERMISSIONS_CALL_LOG"
+reason="$(runh set camera "$probe" maybe | jq -r '.error')"
+[[ -n "$reason" ]] || fail 'an invalid decision was accepted'
+(( "$(wrote)" == 0 )) || fail 'an invalid decision still wrote to the store'
+
+for bad_table in nonsense ../devices 'devices camera' ''; do
+ : >"$PANAMA_PERMISSIONS_CALL_LOG"
+ reason="$(runh set "$bad_table" "$probe" "$verb" | jq -r '.error')"
+ [[ -n "$reason" ]] || fail "the table id '$bad_table' was accepted"
+ (( "$(wrote)" == 0 )) || fail "the table id '$bad_table' reached the store"
+ reason="$(runh forget "$bad_table" "$probe" | jq -r '.error')"
+ [[ -n "$reason" ]] || fail "forgetting the table id '$bad_table' was accepted"
+done
+
+for bad_app in '../../etc/passwd' 'not an app' '' '-'; do
+ : >"$PANAMA_PERMISSIONS_CALL_LOG"
+ reason="$(runh set camera "$bad_app" "$verb" | jq -r '.error')"
+ [[ -n "$reason" ]] || fail "the application id '$bad_app' was accepted"
+ (( "$(wrote)" == 0 )) || fail "the application id '$bad_app' reached the store"
+done
+
+# A refusal answers in the page's own shape rather than collapsing: the page
+# reads .tables off every reply, including the ones that say no.
+jq -e '.tables | type == "object"' <<<"$(runh set nonsense "$probe" "$verb")" >/dev/null \
+ || fail 'a refusal came back without the tables the page is bound to'
+
+printf 'permissions contract: PASS (tables read from a fixture bus, no live portal write)\n'
diff --git a/tests/quickshell/privacy-traces-contract b/tests/quickshell/privacy-traces-contract
new file mode 100755
index 0000000..de9e9d3
--- /dev/null
+++ b/tests/quickshell/privacy-traces-contract
@@ -0,0 +1,354 @@
+#!/usr/bin/env bash
+
+# Clearing the traces this desktop keeps of you: recent files, thumbnails, and
+# the trash.
+#
+# Privacy used to hand all three to GNOME's panel, which is not running in a
+# Hyprland session, so the card that offered them changed nothing. Doing it
+# natively means writing to two paths in the user's home, which is where this
+# stops being a display concern and starts being a thing that can destroy
+# somebody's afternoon. Four rules:
+#
+# 1. Clearing recent files EMPTIES the file, it does not delete it. GTK
+# recreates a missing recently-used.xbel, but not until something writes a
+# recent entry -- so a deleted file reads as "cleared" and then repopulates
+# from whatever GTK still had in memory. An empty, valid xbel document
+# takes effect at once and stays. It also has to remain valid XML: GTK
+# given a truncated file writes nothing there again, and file history
+# silently stops working.
+# 2. Clearing thumbnails stays inside the thumbnail cache. That directory
+# collects symlinks, and an rm that follows one deletes whatever it points
+# at. The fixture plants exactly that trap.
+# 3. The trash has one implementation. Storage already itemizes it, confirms
+# it, and empties it; a second one on this page would be a second set of
+# guards to keep in step, and the one that falls behind is the one nobody
+# is looking at.
+# 4. The card does not sell anything. Every "clean my PC" product manufactures
+# urgency, and the only difference between this card and those is the copy.
+#
+# SAFETY: every run of the helper is under `env -i` with HOME, XDG_DATA_HOME and
+# XDG_CACHE_HOME inside a scratch tree this file created, and the run that
+# deletes anything happens only after the helper has reported the fixture's own
+# byte counts back. The real recently-used.xbel and the real thumbnail cache are
+# never opened.
+
+set -uo pipefail
+
+repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+shell_dir="$repo_dir/config/dot/quickshell"
+helper="$shell_dir/scripts/panama-privacy"
+service="$shell_dir/services/Traces.qml"
+page="$shell_dir/modules/settings/PrivacyPage.qml"
+
+fail() {
+ printf 'privacy traces contract: %s\n' "$1" >&2
+ exit 1
+}
+
+for path in "$helper" "$service" "$page"; do
+ [[ -r "$path" ]] || fail "missing $path"
+done
+[[ -x "$helper" ]] || fail 'panama-privacy is not executable'
+command -v jq >/dev/null 2>&1 || { printf 'privacy traces contract: SKIP (no jq)\n'; exit 0; }
+
+work="$(mktemp -d /tmp/panama-traces.XXXXXX)"
+trap 'rm -rf "$work"' EXIT
+
+# ── The helper can be pointed somewhere else at all ─────────────────────────
+#
+# Everything below rests on this: a hardcoded /home/… would mean the run that
+# deletes is deleting from the real home.
+grep -n '"/home/' "$helper" \
+ && fail 'the helper hardcodes a path under /home, so it cannot be pointed at a fixture'
+
+for verb in traces clear-recents clear-thumbnails; do
+ grep -q -- "$verb" "$helper" || fail "the helper has no $verb command"
+done
+
+# Trash is deliberately absent from this helper. Rule 3, said where it would be
+# broken first. Read past the docstrings, which are allowed to say the word --
+# and in fact should, since "the trash is Storage's" is the reason.
+python3 - "$helper" <<'PY' || fail 'panama-privacy has grown a trash implementation; Storage already has one'
+import ast
+import sys
+
+tree = ast.parse(open(sys.argv[1], encoding="utf-8").read())
+
+# Drop every docstring before looking at what is left.
+for node in ast.walk(tree):
+ if isinstance(node, (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
+ body = getattr(node, "body", [])
+ if body and isinstance(body[0], ast.Expr) and isinstance(body[0].value, ast.Constant) \
+ and isinstance(body[0].value.value, str):
+ node.body = body[1:]
+
+offenders = sorted({inner.value for inner in ast.walk(tree)
+ if isinstance(inner, ast.Constant) and isinstance(inner.value, str)
+ and "trash" in inner.value.lower()})
+offenders += sorted({inner.id for inner in ast.walk(tree)
+ if isinstance(inner, ast.Name) and "trash" in inner.id.lower()})
+if offenders:
+ raise SystemExit(f"the helper's code mentions the trash: {offenders}")
+PY
+
+# ── The fixture home ────────────────────────────────────────────────────────
+
+home="$work/home"
+data="$home/.local/share"
+cache="$home/.cache"
+recents="$data/recently-used.xbel"
+thumbs="$cache/thumbnails"
+mkdir -p "$data" "$thumbs/normal" "$thumbs/large" "$work/outside"
+
+# A recents file with two entries, of a size that could not be confused with a
+# real one.
+cat >"$recents" <<'XBEL'
+
+
+
+
+
+
+
+
+
+
+
+
+
+XBEL
+
+# 9 thumbnails of 33333 bytes: ~300 KB, a number that appears nowhere else.
+for index in $(seq 1 9); do
+ head -c 33333 /dev/zero >"$thumbs/normal/fixture-$index.png"
+done
+
+# The trap. A symlink out of the thumbnail cache to a file that must survive,
+# and a symlinked subdirectory, which has to be unlinked rather than descended.
+printf 'this file is not a thumbnail and must survive\n' >"$work/outside/precious"
+ln -s "$work/outside/precious" "$thumbs/escape-file"
+ln -s "$work/outside" "$thumbs/escape-dir"
+
+runh() {
+ env -i \
+ PATH="/usr/bin:/bin" \
+ HOME="$home" \
+ XDG_DATA_HOME="$data" \
+ XDG_CACHE_HOME="$cache" \
+ XDG_CONFIG_HOME="$home/.config" \
+ LANG=C LC_ALL=C \
+ "$helper" "$@"
+}
+
+# ── The measurement, and the proof it is the fixture's ──────────────────────
+
+state="$(runh traces)" || fail 'traces failed against the fixture'
+jq -e '(.recents | has("bytes") and has("entries")) and (.thumbnails | has("bytes"))
+ and (.error == "" or .error == null)' <<<"$state" >/dev/null \
+ || fail "traces is not the shape the card is bound to: $state"
+
+# Nothing that deletes runs until these two hold. 9 x 33333 = 299997 bytes of
+# thumbnails, and two recent entries.
+jq -e '.thumbnails.bytes > 250000 and .thumbnails.bytes < 400000' <<<"$state" >/dev/null \
+ || fail "the thumbnail measurement is not the fixture's ($(jq -r .thumbnails.bytes <<<"$state") bytes); refusing to go on"
+jq -e '.recents.entries == 2' <<<"$state" >/dev/null \
+ || fail "the recents count is not the fixture's ($(jq -r .recents.entries <<<"$state")); refusing to go on"
+jq -e '.recents.bytes > 0' <<<"$state" >/dev/null \
+ || fail 'the recents file was measured as empty when it plainly is not'
+
+# ── 1. Clearing recents empties the file, and leaves valid XML ──────────────
+
+before_inode="$(stat -c '%i' "$recents")"
+runh clear-recents >/dev/null || fail 'clear-recents failed against the fixture'
+
+[[ -f "$recents" ]] \
+ || fail 'clear-recents deleted recently-used.xbel; GTK will not recreate it until something writes a recent entry, so file history stops working until then'
+[[ ! -L "$recents" ]] || fail 'recently-used.xbel was replaced by a symlink'
+
+python3 - "$recents" <<'PY' || fail 'clear-recents left something that is not a valid, empty xbel document'
+import sys
+import xml.etree.ElementTree as ET
+
+path = sys.argv[1]
+raw = open(path, "rb").read()
+if not raw.strip():
+ raise SystemExit("the file was truncated to nothing rather than written as an empty xbel; "
+ "GTK treats a zero-length file as corrupt and stops recording history")
+try:
+ root = ET.fromstring(raw)
+except ET.ParseError as error:
+ raise SystemExit(f"what is left is not parseable XML: {error}")
+if root.tag != "xbel":
+ raise SystemExit(f"the root element is <{root.tag}>, not ")
+bookmarks = root.findall("bookmark")
+if bookmarks:
+ raise SystemExit(f"{len(bookmarks)} bookmark(s) survived clearing")
+PY
+
+after="$(runh traces)"
+jq -e '.recents.entries == 0' <<<"$after" >/dev/null \
+ || fail "recents still reports $(jq -r .recents.entries <<<"$after") entries after clearing"
+
+# Rewritten in place or replaced atomically -- either is fine; unlinking and
+# leaving nothing is what rule 1 forbids, and that is already covered above.
+# What is checked here is that a second clear on an already-empty file is not
+# an error, because the row stays pressable.
+runh clear-recents >/dev/null || fail 'clearing an already-empty recents file failed'
+[[ -f "$recents" ]] || fail 'the second clear removed the file'
+: "$before_inode"
+
+# ── 2. Clearing thumbnails stays inside the thumbnail cache ─────────────────
+
+runh clear-thumbnails >/dev/null || fail 'clear-thumbnails failed against the fixture'
+
+[[ -f "$work/outside/precious" ]] \
+ || fail 'clearing thumbnails followed a symlink out of the cache and deleted a file elsewhere'
+[[ -d "$work/outside" ]] \
+ || fail 'clearing thumbnails deleted a directory outside the cache'
+[[ -d "$thumbs" ]] \
+ || fail 'the thumbnail directory itself was removed; the thumbnailer expects it to exist'
+remaining="$(find "$thumbs" -type f | wc -l)"
+[[ "$remaining" == "0" ]] \
+ || fail "clearing thumbnails left $remaining file(s) behind, so it did not do what it said"
+
+# The escape hatch itself is gone -- the link is inside the cache, so removing
+# it is correct; what must not have happened is following it.
+[[ ! -e "$thumbs/escape-file" ]] \
+ || fail 'the symlink inside the cache was left behind'
+
+# ── The symlinked cache root, refused rather than followed ──────────────────
+#
+# The other half of the same trap: not a link inside the cache, but a cache
+# that IS a link. Resolving to somewhere outside the home has to be refused
+# with a reason rather than emptied.
+
+escaped_home="$work/escaped"
+mkdir -p "$escaped_home/.cache" "$work/victim"
+printf 'not a thumbnail\n' >"$work/victim/keepme"
+ln -s "$work/victim" "$escaped_home/.cache/thumbnails"
+
+escaped_output="$(env -i PATH="/usr/bin:/bin" HOME="$escaped_home" \
+ XDG_CACHE_HOME="$escaped_home/.cache" XDG_DATA_HOME="$escaped_home/.local/share" \
+ LANG=C LC_ALL=C "$helper" clear-thumbnails 2>&1)"
+escaped_status=$?
+
+[[ -f "$work/victim/keepme" ]] \
+ || fail 'a symlinked thumbnail directory was emptied through the link'
+[[ -d "$work/victim" ]] \
+ || fail 'the directory a symlinked thumbnail cache pointed at was removed'
+
+# Refused in words. Either shape counts -- an error field on the payload the
+# card reads, or a non-zero exit -- but silence does not: a row that reports
+# success while the cache is untouched is the failure mode.
+reason="$(jq -r '.error // ""' <<<"$escaped_output" 2>/dev/null || printf '')"
+[[ -n "$reason" || "$escaped_status" -ne 0 ]] \
+ || fail 'a thumbnail cache that resolves outside the home was accepted silently'
+
+# ── The seam is not a way around the guard ──────────────────────────────────
+#
+# A test seam that skips the confinement would make everything above theatre.
+# Pointed at a directory outside the home, it has to be refused exactly as a
+# symlinked one is.
+
+mkdir -p "$work/elsewhere"
+printf 'also not a thumbnail\n' >"$work/elsewhere/keepme"
+seam_output="$(env -i PATH="/usr/bin:/bin" HOME="$home" \
+ XDG_DATA_HOME="$data" XDG_CACHE_HOME="$cache" \
+ PANAMA_PRIVACY_THUMBNAILS="$work/elsewhere" \
+ LANG=C LC_ALL=C "$helper" clear-thumbnails 2>&1)"
+seam_status=$?
+[[ -f "$work/elsewhere/keepme" ]] \
+ || fail 'the thumbnail seam pointed outside the home was emptied anyway'
+reason="$(jq -r '.error // ""' <<<"$seam_output" 2>/dev/null || printf '')"
+[[ -n "$reason" || "$seam_status" -ne 0 ]] \
+ || fail 'a thumbnail path outside the home was accepted through the test seam'
+
+# ── 3. One trash implementation ─────────────────────────────────────────────
+
+grep -q 'Disks' "$page" \
+ || fail 'the Traces card does not reach the Storage service, so its trash row is a second implementation'
+grep -qE 'Disks\.(clean|cleanables)' "$page" \
+ || fail 'the page does not empty the trash through the cleanable Storage already has'
+
+# The two ways a second one would appear: the page doing it itself, or the
+# Traces service growing the verb.
+grep -vE '^\s*//' "$page" | grep -qE 'gio +trash|"trash-empty"|rm -rf.*Trash|\.local/share/Trash' \
+ && fail 'the page empties the trash itself rather than through Storage'
+grep -vE '^\s*//' "$service" | grep -qiE 'trash' \
+ && fail 'the Traces service has grown a trash path; Storage owns that one'
+
+# The row says so, rather than leaving two identical buttons in two places
+# looking like two different things.
+grep -q 'Trash' "$page" \
+ || fail 'the Traces card has no trash row at all'
+
+# ── The seams, so this contract can exist ───────────────────────────────────
+#
+# Both paths are named seams AND both are re-confined against HOME, which is
+# what makes pointing HOME at a scratch tree a real test rather than a way of
+# disabling the guard.
+
+for seam in PANAMA_PRIVACY_RECENTS PANAMA_PRIVACY_THUMBNAILS; do
+ grep -q "$seam" "$helper" \
+ || fail "the helper has no $seam seam, so nothing can exercise it without the real home"
+done
+grep -qE 'PANAMA_PRIVACY|helperPath' "$service" \
+ || fail 'the Traces service does not name the helper it runs'
+
+# ── 4. The card does not sell anything ──────────────────────────────────────
+
+python3 - "$page" <<'PY' || fail 'the Traces card uses the language of a cleaner racket'
+import re
+import sys
+
+source = open(sys.argv[1], encoding="utf-8").read()
+text = "\n".join(line for line in source.splitlines() if not line.strip().startswith("//"))
+
+
+def block_at(start: int) -> str:
+ depth = 0
+ for index in range(text.find("{", start), len(text)):
+ if text[index] == "{":
+ depth += 1
+ elif text[index] == "}":
+ depth -= 1
+ if depth == 0:
+ return text[start:index + 1]
+ return ""
+
+
+# Found by what it is wired to rather than by its title: the card that reads
+# the Traces service is the card under test, whatever it ends up being called.
+cards = [block for block in (block_at(match.start())
+ for match in re.finditer(r"SettingsCard \{", text))
+ if re.search(r"\bTraces\.", block)]
+if not cards:
+ raise SystemExit("no card on the Privacy page is wired to the Traces service")
+card = min(cards, key=len)
+
+# Only what a person reads. QML is full of exclamation marks and none of them
+# are shouting at anybody.
+copy = " ".join(re.findall(r'"([^"\n]*)"', card)).lower()
+
+PRESSURE = [
+ "running out", "running low", "act now", "recommended", "we recommend",
+ "urgent", "boost", "speed up", "optimize", "optimise", "reclaim now",
+ "free up now", "clean now", "junk", "safe to remove", "you should",
+ "needs attention", "protect yourself", "at risk", "exposed", "!",
+]
+found = [phrase for phrase in PRESSURE if phrase in copy]
+if found:
+ raise SystemExit(f"the Traces card says: {found}")
+
+# Nor one button that clears the lot: each trace is a separate thing to lose,
+# and losing all three because one of them was worth clearing is not a choice
+# anybody made.
+if re.search(r'"(Clear (everything|all)|Erase everything|Wipe)"', card):
+ raise SystemExit("the card offers a single button that clears every trace at once")
+PY
+
+printf 'privacy traces contract: PASS (recents emptied as valid xbel, thumbnails confined, trash still Storage\047s)\n'
diff --git a/tests/quickshell/secrets-contract b/tests/quickshell/secrets-contract
index d89d63b..cb045e9 100755
--- a/tests/quickshell/secrets-contract
+++ b/tests/quickshell/secrets-contract
@@ -69,7 +69,7 @@ grep -qiE 'property (string|var) (secret|password|value)\b' "$service" \
&& fail 'the Keyring service declares a property that would hold a secret value'
# ── 4. Forgetting is confirmed ───────────────────────────────────────────────
-grep -q 'confirmingPath' "$page" \
+grep -q 'confirmingItem' "$page" \
|| fail 'the page deletes a stored secret without a confirmation step'
grep -q 'Keyring.forget(' "$page" \
|| fail 'the page cannot forget a secret at all'
@@ -79,9 +79,78 @@ grep -q 'Keyring.forget(' "$page" \
# for confirmation rather than a deletion.
grep -q 'if (!secretRow.confirming)' "$page" \
|| fail 'the first press on Forget is not turned into a confirmation step'
-grep -q 'root.confirmingPath = secretRow.itemPath;' "$page" \
+grep -q 'root.confirmingItem = secretRow.itemPath;' "$page" \
|| fail 'nothing records which item is awaiting confirmation'
+# ── 5. Confirming a Forget does not move the Copy button ─────────────────────
+#
+# The page used to run one confirmation state for the whole card, and the row's
+# other button read `confirming ? "Cancel" : "Copy"`. So arming Forget on a row
+# replaced the word "Copy" -- in the exact place the user had just learnt to
+# find it -- with "Cancel", and the way out of a confirmation was to press the
+# button that copies. Two states that happen to be about the same row are still
+# two states.
+python3 - "$page" <<'PY' || fail 'the copy button doubles as something else'
+import re
+import sys
+
+text = "\n".join(line for line in open(sys.argv[1], encoding="utf-8").read().splitlines()
+ if not line.strip().startswith("//"))
+
+
+def block_at(start: int) -> str:
+ depth = 0
+ for index in range(text.find("{", start), len(text)):
+ if text[index] == "{":
+ depth += 1
+ elif text[index] == "}":
+ depth -= 1
+ if depth == 0:
+ return text[start:index + 1]
+ return ""
+
+
+buttons = [block_at(match.start()) for match in re.finditer(r"SettingsButton \{", text)]
+copiers = [block for block in buttons if "Keyring.copy(" in block]
+if not copiers:
+ raise SystemExit("nothing on the page copies a stored secret")
+for block in copiers:
+ label = re.search(r'text:\s*(.+)', block)
+ if label is None:
+ raise SystemExit("the copy button has no label")
+ if label.group(1).strip().rstrip(";") != '"Copy"':
+ raise SystemExit(f"the copy button's label is conditional: {label.group(1).strip()}")
+
+ # ...and its press does one thing. The old Cancel behaviour lived in the
+ # handler as well as the label: pressing Copy while a Forget was armed
+ # cleared the confirmation instead of copying.
+ handler = re.search(r'onClicked:\s*(\{.*?\n\s*\}|[^\n]+)', block, re.S)
+ if handler is None:
+ raise SystemExit("the copy button does nothing when pressed")
+ if re.search(r'confirming', handler.group(1)):
+ raise SystemExit(f"pressing Copy reads the confirmation state: {handler.group(1).strip()}")
+
+forgetters = [block for block in buttons if "Keyring.forget(" in block]
+if not forgetters:
+ raise SystemExit("nothing on the page forgets a stored secret")
+for block in forgetters:
+ if "Keyring.copy(" in block:
+ raise SystemExit("one button both copies and forgets")
+PY
+
+# ── 6. A copy says it happened, on its own row ───────────────────────────────
+#
+# Putting something on the clipboard is invisible. The confirmation has to be
+# tied to the row it happened on, or a card of six identical Copy buttons says
+# only that A copy happened.
+grep -qE 'Keyring\.copiedPath === [A-Za-z_][A-Za-z0-9_]*\.itemPath' "$page" \
+ || fail 'the copy confirmation is not tied to the row that was copied'
+grep -q 'copiedPath' "$service" \
+ || fail 'the service records nothing about a copy, so no row can confirm one'
+# ...and the confirmation is not the same state as the Forget confirmation.
+grep -qE 'confirmingItem[^=]*=[^=].*copiedPath|copiedPath.*=.*confirmingItem' "$page" \
+ && fail 'the confirm state and the copy state are the same value'
+
# Opening the page must not enumerate anyone's passwords as a side effect.
grep -qE 'Component.onCompleted:.*Keyring.list\(\)' "$page" \
&& fail 'the page lists stored secrets when it opens rather than when asked'
diff --git a/tests/quickshell/settings-ownership-contract b/tests/quickshell/settings-ownership-contract
index 68cac00..fb6f3c3 100755
--- a/tests/quickshell/settings-ownership-contract
+++ b/tests/quickshell/settings-ownership-contract
@@ -40,8 +40,12 @@ expected = {
"cursorInactiveTimeout": {"owner": "mouse", "mirrors": {"accessibility"}},
"cursorSize": {"owner": "accessibility", "mirrors": {"mouse"}},
"inactiveOpacity": {"owner": "appearance", "mirrors": {"accessibility"}},
- "lockMinutes": {"owner": "power", "mirrors": {"privacy"}},
- "lockOnSleep": {"owner": "power", "mirrors": {"privacy"}},
+ # lockMinutes and lockOnSleep used to be mirrored onto Privacy, which was
+ # the one mirror in this table that nobody had asked for: Privacy carried a
+ # whole second Screen-lock card, so the same preference had two sliders and
+ # the page you were looking at might not be the one you last set. The card
+ # is gone and Privacy points at Power & Lock instead, so the mirror went
+ # with it. Power owns them outright now.
}
@@ -103,8 +107,6 @@ for needle in \
'`cursorInactiveTimeout`' \
'`cursorSize`' \
'`inactiveOpacity`' \
- '`lockMinutes`' \
- '`lockOnSleep`' \
'scheme-relative role' \
'mode, scale, rotation, arrangement, and primary role'; do
rg -Fq "$needle" "$readme" || fail "README is missing $needle"
diff --git a/tests/quickshell/ssh-keys-contract b/tests/quickshell/ssh-keys-contract
index 77e28c8..b2fd07b 100755
--- a/tests/quickshell/ssh-keys-contract
+++ b/tests/quickshell/ssh-keys-contract
@@ -1,32 +1,45 @@
#!/usr/bin/env bash
-# SSH keys, and the two things this page must never do.
+# SSH keys, and the things this page must never do.
#
# The rules:
#
# 1. A private key is never read for its contents and never leaves the
# machine's disk. Fingerprints and comments come from the .pub file.
-# 2. No passphrase passes through this tool. Adding an encrypted key lets
-# ssh-add prompt through the system's own askpass; collecting one here and
-# handing it on would be a worse place for it to live, and putting one in
-# argv would publish it to every process on the machine.
+# 2. A passphrase never reaches argv, an environment variable, or a temporary
+# file. /proc publishes argv and the environment to every process on this
+# machine, and a file on disk outlives the moment it was needed for. The
+# page can now MAKE a key, which means it collects one -- so the rule
+# stopped being "no passphrase exists here" and became "the passphrase
+# goes down a pty to ssh-keygen and nowhere else". `ssh-keygen -N` is the
+# easy way to do this wrong and is forbidden outright. Adding an existing
+# encrypted key to the agent still collects nothing: ssh-add prompts
+# through the system's own askpass.
# 3. Key paths are confined to ~/.ssh, resolved and compared, so a name cannot
-# walk out of the directory.
+# walk out of the directory -- and generation refuses to overwrite, because
+# the one thing worse than not making a key is replacing one whose public
+# half is already installed on servers you can no longer reach.
# 4. A control that cannot do what it says is not offered. gnome-keyring's
# agent lists every key it finds in ~/.ssh, so `ssh-add -d` reports
# "Identity removed" and the key is still offered a second later. Measured
# on this machine: a plain ssh-agent removes durably, that one does not.
-# 5. Copying a public key never splices a path into shell source.
+# The button exists now, and says so.
+# 5. Copying a public key never splices a path into shell source, and says
+# that it happened.
#
-# Read-only against the real configuration. Nothing here adds, removes or
-# rewrites a key, an agent entry, or a known host.
+# SAFETY: the first half is read-only against the real configuration -- nothing
+# adds, removes or rewrites a key, an agent entry, or a known host. The second
+# half runs the helper under `env -i` with HOME inside a scratch tree and a
+# recording `ssh-keygen` first on PATH, so every key it creates, refuses or
+# chmods is one this file made. `~/.ssh` is never the directory under test.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
-helper="$repo_dir/config/dot/quickshell/scripts/panama-ssh-keys"
-service="$repo_dir/config/dot/quickshell/services/SshKeys.qml"
-page="$repo_dir/config/dot/quickshell/modules/settings/SshKeysPage.qml"
+shell_dir="$repo_dir/config/dot/quickshell"
+helper="$shell_dir/scripts/panama-ssh-keys"
+service="$shell_dir/services/SshKeys.qml"
+page="$shell_dir/modules/settings/SshKeysPage.qml"
fail() {
printf 'ssh keys contract: %s\n' "$1" >&2
@@ -65,12 +78,98 @@ for key in state['keys']:
grep -q 'read_text' "$helper" && ! grep -q 'KNOWN_HOSTS.read_text' "$helper" \
&& fail 'something reads a file directly that is not known_hosts'
-# ── 2. No passphrase anywhere ───────────────────────────────────────────────
+# ── 2. The passphrase, statically ───────────────────────────────────────────
grep -qE '\-N["'"'"' ]' "$helper" \
&& fail 'ssh-keygen -N appears, which would put a passphrase in argv'
-grep -qi 'passphrase' "$service" && ! grep -qi 'never\|prompt' "$service" \
- && fail 'the service mentions passphrases without saying it does not handle them'
+
+grep -qE '\bimport pty\b|openpty' "$helper" \
+ || fail 'nothing opens a pty, so ssh-keygen has no terminal to read a passphrase from'
+
+# Where it must not go, read from the syntax tree rather than by eye: the
+# parameter carrying the passphrase may not appear inside any command list, any
+# environment dict, or any call that opens a file.
+python3 - "$helper" <<'PY' || fail 'the passphrase can reach somewhere other than the pty'
+import ast
+import sys
+
+tree = ast.parse(open(sys.argv[1], encoding="utf-8").read())
+
+functions = [node for node in ast.walk(tree)
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))]
+if not any("generate" in node.name for node in functions):
+ raise SystemExit("the helper has no generate function")
+
+# Every function that is handed the passphrase, not only the one named
+# generate: the pty write lives in a helper of its own, and a rule that only
+# looked at the caller would miss the place the value actually goes.
+carriers = []
+for node in functions:
+ arguments = node.args
+ names = [argument.arg for argument in
+ arguments.posonlyargs + arguments.args + arguments.kwonlyargs]
+ for name in names:
+ if "pass" in name.lower() or "secret" in name.lower():
+ carriers.append((node, name))
+
+if not carriers:
+ raise SystemExit("no function in the helper takes a passphrase, so nothing collects one "
+ "and the page cannot make an encrypted key")
+
+
+def mentions(node, secret) -> bool:
+ return any(isinstance(inner, ast.Name) and inner.id == secret
+ for inner in ast.walk(node))
+
+
+for function, secret in carriers:
+ where = f"{function.name}()"
+ for node in ast.walk(function):
+ if isinstance(node, (ast.List, ast.Tuple)) and mentions(node, secret):
+ raise SystemExit(f"{secret} appears inside a list literal in {where}, "
+ "which is how it gets into argv")
+ if isinstance(node, ast.Dict) and mentions(node, secret):
+ raise SystemExit(f"{secret} appears inside a dict literal in {where}, "
+ "which is how it gets into the environment")
+ if isinstance(node, ast.Call):
+ called = node.func
+ label = getattr(called, "id", None) or getattr(called, "attr", None) or ""
+ if label in {"open", "NamedTemporaryFile", "mkstemp", "write_text", "write_bytes"} \
+ and any(mentions(argument, secret) for argument in node.args):
+ raise SystemExit(f"{secret} is handed to {label}() in {where}, "
+ "which puts it on disk")
+ if label in {"putenv", "setenv"} and mentions(node, secret):
+ raise SystemExit(f"{secret} is put into the environment in {where}, "
+ "which /proc publishes")
+
+# The empty-passphrase escape hatch exists, is explicit, and is not the default.
+flags = {node.value for node in ast.walk(tree)
+ if isinstance(node, ast.Constant) and isinstance(node.value, str)}
+if "--no-passphrase" not in flags:
+ raise SystemExit("there is no explicit --no-passphrase flag, so an empty passphrase "
+ "is either impossible or silent")
+PY
+
+# ...and nothing in the shell ever passes that flag. An unencrypted key is a
+# decision someone makes at a terminal, not one a settings page makes quietly.
+offenders="$(grep -rn --include='*.qml' -- '--no-passphrase' "$shell_dir" || true)"
+[[ -z "$offenders" ]] \
+ || fail "the shell passes --no-passphrase, so the page can make an unencrypted key: $offenders"
+
+# The service holds one for exactly as long as it takes to write it down the
+# pipe, and never assembles it into a command.
+grep -q 'stdinEnabled' "$service" \
+ || fail 'the service has no stdin path, so a passphrase would have to travel some other way'
+python3 - "$service" <<'PY' || fail 'the service puts a passphrase into a command'
+import re
+import sys
+
+text = "\n".join(line for line in open(sys.argv[1], encoding="utf-8").read().splitlines()
+ if not line.strip().startswith("//"))
+for match in re.finditer(r'command\s*[:=]\s*\[[^\]]*\]', text, re.S):
+ if re.search(r'passphrase', match.group(0), re.I):
+ raise SystemExit(f"a passphrase is spliced into a command: {match.group(0)!r}")
+PY
# ── 3. Paths are confined ───────────────────────────────────────────────────
@@ -94,13 +193,23 @@ reason="$(printf '%s' "$("$helper" forget-host 'not a host name')" | field "['er
grep -q 'durableRemoval' "$helper" \
|| fail 'the helper does not record whether removal from this agent sticks'
+# Removal is reachable now, rather than being a verb the helper had and nothing
+# called. All three links have to exist or the button is decoration.
+grep -q 'agent-remove' "$service" \
+ || fail 'the service never invokes agent-remove, so the helper verb is unreachable'
+grep -qE 'function removeFromAgent' "$service" \
+ || fail 'the service has no removeFromAgent, so the page has nothing to call'
+grep -q 'removeFromAgent(' "$page" \
+ || fail 'the page never removes a key from the agent, so the verb is still unreachable'
+
kind="$(printf '%s' "$state" | field "['agent'].get('kind','')")"
if [[ "$kind" == "gnome-keyring" ]]; then
# Whichever key this machine actually has. This used to hardcode
# id_ed25519, which asserted the author's machine: any other key name
# earned "That key no longer exists" instead of the refusal under test.
# No key at all means the property cannot be exercised here, not that it
- # failed.
+ # failed. Nothing is removed either way -- the refusal happens before
+ # ssh-add is invoked.
real_key="$(compgen -G "$HOME/.ssh/id_*.pub" | head -1)"
real_key="${real_key%.pub}"
if [[ -n "$real_key" ]]; then
@@ -112,9 +221,249 @@ if [[ "$kind" == "gnome-keyring" ]]; then
|| fail 'the page does not say that removing a key from this agent has no effect'
fi
-# ── 5. Copying does not build shell source from a path ──────────────────────
+# The refusal is prose on the page, not a silent no-op: someone pressing Remove
+# and watching the key stay is owed the reason.
+grep -qE 'lastError|durableRemoval' "$page" \
+ || fail 'the page surfaces neither the refusal nor the reason for it'
+
+# ── 5. Copying does not build shell source from a path, and says it happened ─
grep -q 'exec wl-copy < "\$1"' "$service" \
|| fail 'the public key copy does not pass its path as an argument'
+grep -q 'copiedKey' "$service" \
+ || fail 'the service records nothing about a copy, so the button cannot confirm it happened'
+grep -qE 'Timer' "$service" \
+ || fail 'the copy confirmation is never cleared, so the page stays stuck saying Copied'
+grep -q 'copiedKey' "$page" \
+ || fail 'the page does not show that a public key was copied'
-printf 'ssh keys contract: ok\n'
+# The page can ask for the state again rather than only at startup.
+grep -qE 'SshKeys\.refresh\(\)' "$page" \
+ || fail 'the page cannot refresh, so a key made in a terminal never appears'
+
+# ══ The hermetic half ═══════════════════════════════════════════════════════
+#
+# Everything above reads. Everything below writes -- into a scratch home, with
+# a recording ssh-keygen, and never anywhere near ~/.ssh.
+
+command -v jq >/dev/null 2>&1 || { printf 'ssh keys contract: SKIP hermetic half (no jq)\n'; exit 0; }
+
+work="$(mktemp -d /tmp/panama-ssh-keys.XXXXXX)"
+trap 'rm -rf "$work"' EXIT
+
+scratch="$work/home"
+ssh_dir="$scratch/.ssh"
+mkdir -p "$ssh_dir" "$work/bin" "$work/outside"
+chmod 700 "$ssh_dir"
+
+log="$work/keygen.log"
+: >"$log"
+
+# The recording ssh-keygen. It logs its own argv and its own environment,
+# straight out of /proc, so the passphrase check is made against what the
+# kernel would show any other process rather than against what the stub was
+# told. Then it plays the prompts and reads the answers off its terminal.
+cat >"$work/bin/ssh-keygen" <>"\$log"
+
+mode=""
+keyfile=""
+comment=""
+previous=""
+for argument in "\$@"; do
+ case "\$argument" in
+ -t) mode=generate ;;
+ -y) mode=derive ;;
+ -l) mode=fingerprint ;;
+ -R) mode=forget ;;
+ esac
+ case "\$previous" in
+ -f) keyfile="\$argument" ;;
+ -C) comment="\$argument" ;;
+ esac
+ previous="\$argument"
+done
+
+case "\$mode" in
+ generate)
+ printf 'Enter passphrase (empty for no passphrase): '
+ IFS= read -r first
+ printf '\nEnter same passphrase again: '
+ IFS= read -r second
+ printf '\n'
+ printf 'pty-read: %s\n' "\$first" >>"\$log"
+ printf 'pty-read: %s\n' "\$second" >>"\$log"
+ [[ -n "\$keyfile" ]] || exit 1
+ printf 'fixture private key, not real material\n' >"\$keyfile"
+ chmod 600 "\$keyfile"
+ printf 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFIXTURE %s\n' "\$comment" >"\$keyfile.pub"
+ printf 'Your identification has been saved in %s\n' "\$keyfile"
+ exit 0 ;;
+ derive)
+ # An encrypted key: deriving the public half with an empty passphrase
+ # fails, which is how the helper answers "is this one encrypted".
+ printf 'Load key "%s": incorrect passphrase supplied to decrypt private key\n' \
+ "\$keyfile" >&2
+ exit 1 ;;
+ fingerprint)
+ printf '256 SHA256:FIXTUREFINGERPRINTAAAAAAAAAAAAAAAAAAAAAAAAA fixture (ED25519)\n'
+ exit 0 ;;
+ forget)
+ exit 0 ;;
+esac
+exit 0
+STUB
+chmod +x "$work/bin/ssh-keygen"
+
+# ssh-add must never be reached here. If something calls it, that is the
+# failure, so the stub records and refuses rather than doing anything.
+cat >"$work/bin/ssh-add" <>"$log"
+exit 2
+STUB
+chmod +x "$work/bin/ssh-add"
+
+resolved="$(env -i PATH="$work/bin:/usr/bin:/bin" command -v ssh-keygen)"
+[[ "$resolved" == "$work/bin/ssh-keygen" ]] \
+ || fail "ssh-keygen resolves to $resolved, not the stub; refusing to generate anything"
+
+runh() {
+ env -i \
+ PATH="$work/bin:/usr/bin:/bin" \
+ HOME="$scratch" \
+ XDG_RUNTIME_DIR="$work/run" \
+ LANG=C LC_ALL=C \
+ "$helper" "$@"
+}
+mkdir -p "$work/run"
+
+# The proof that the helper is looking at the scratch home before anything is
+# written into it.
+[[ "$(runh snapshot | jq -r '.directory')" == "$ssh_dir" ]] \
+ || fail "the helper reports $(runh snapshot | jq -r '.directory') as its SSH directory, not the scratch one; refusing to go on"
+
+# Deliberately does not contain the word this contract greps for in prompts:
+# the pty echoes what is typed, and a value that reads like a prompt would make
+# the transcript ambiguous.
+PASSPHRASE='Contract-Secret-9c1f-do-not-log-me'
+
+generate() {
+ printf '%s\n' "$PASSPHRASE" | runh generate "$@"
+}
+
+# ── Generation, and where the passphrase went ───────────────────────────────
+
+: >"$log"
+result="$(generate contractkey 'panama contract fixture')" \
+ || fail 'generate failed against the scratch home'
+reason="$(jq -r '.error // ""' <<<"$result")"
+[[ -z "$reason" ]] || fail "generating a key was refused: $reason"
+
+[[ -f "$ssh_dir/contractkey" ]] || fail 'generate reported success and made no private key'
+[[ -f "$ssh_dir/contractkey.pub" ]] || fail 'generate made no public key'
+mode="$(stat -c '%a' "$ssh_dir/contractkey")"
+[[ "$mode" == "600" ]] || fail "a freshly made private key is mode $mode, which ssh refuses to use"
+
+grep -q 'argv: .*ed25519' "$log" || fail "ssh-keygen was not asked for an ed25519 key: $(cat "$log")"
+
+# The environment ssh-keygen runs in, read back out of /proc rather than out of
+# the source. Two things have to be true there, and both were found the hard
+# way:
+#
+# * SSH_ASKPASS_REQUIRE=never. This desktop sets it to "prefer", which makes
+# ssh-keygen draw a graphical passphrase dialog even with a perfectly good
+# terminal in front of it -- so the prompt appears on somebody's screen, the
+# pty sees nothing, and generation hangs until the timeout.
+# * LC_ALL=C. The prompts are matched by their words; a translated ssh-keygen
+# would never be answered.
+grep -q 'environ: .*SSH_ASKPASS_REQUIRE=never' "$log" \
+ || fail "ssh-keygen was not told to ignore askpass, so a graphical prompt can steal the passphrase question: $(grep '^environ: ' "$log" | head -1)"
+grep -q 'environ: .*LC_ALL=C' "$log" \
+ || fail 'ssh-keygen was not pinned to the C locale, so its prompts may not be the ones being matched'
+
+# THE rule. Read from what /proc showed the process, both halves.
+if grep -E '^(argv|environ): ' "$log" | grep -qF "$PASSPHRASE"; then
+ fail 'the passphrase appeared in ssh-keygen argv or environment, where every process on this machine can read it'
+fi
+grep -qF "pty-read: $PASSPHRASE" "$log" \
+ || fail "the passphrase never arrived down the terminal, so ssh-keygen cannot have used it: $(cat "$log")"
+[[ "$(grep -c "pty-read: $PASSPHRASE" "$log")" == "2" ]] \
+ || fail 'the passphrase was not confirmed to ssh-keygen twice, so generation would have stalled at the second prompt'
+
+# It is not left lying around in the answer the page reads, either.
+grep -qF "$PASSPHRASE" <<<"$result" \
+ && fail 'the passphrase came back in the JSON the page parses'
+
+# Nor on disk anywhere under the scratch tree except inside the key ssh-keygen
+# itself wrote.
+found="$(grep -rlF "$PASSPHRASE" "$scratch" "$work/run" 2>/dev/null || true)"
+[[ -z "$found" ]] || fail "the passphrase was written to disk: $found"
+
+# The snapshot comes back with the new key in it, so the page does not need a
+# second round trip to see what it just made.
+jq -e '[.keys[] | select(.name == "contractkey")] | length == 1' <<<"$result" >/dev/null \
+ || fail "generate did not return a snapshot containing the new key: $result"
+
+# ── Overwrite is refused ────────────────────────────────────────────────────
+
+original="$(cat "$ssh_dir/contractkey")"
+: >"$log"
+reason="$(generate contractkey 'a second time' | jq -r '.error // ""')"
+[[ -n "$reason" ]] || fail 'generating over an existing key was accepted'
+[[ "$(cat "$ssh_dir/contractkey")" == "$original" ]] \
+ || fail 'the existing private key was overwritten; its public half is on servers that are now unreachable'
+grep -q 'argv: .*ed25519' "$log" \
+ && fail 'the overwrite was refused only after ssh-keygen had already run'
+
+# ── Names are confined ──────────────────────────────────────────────────────
+
+printf 'do not touch me\n' >"$work/outside/target"
+
+for bad in '../outside/target' '../../outside/target' 'sub/dir' '.' '..' '' \
+ 'name with spaces' 'name;rm -rf' '/etc/hostname' \
+ 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; do
+ : >"$log"
+ reason="$(generate "$bad" 'confinement probe' | jq -r '.error // ""')"
+ [[ -n "$reason" ]] || fail "generate accepted the key name '$bad'"
+ grep -q 'argv: .*ed25519' "$log" \
+ && fail "the key name '$bad' reached ssh-keygen before being refused"
+done
+[[ "$(cat "$work/outside/target")" == "do not touch me" ]] \
+ || fail 'a key name walked out of the SSH directory and overwrote a file'
+[[ "$(find "$ssh_dir" -maxdepth 1 -type f | wc -l)" == "2" ]] \
+ || fail "the scratch SSH directory holds $(find "$ssh_dir" -maxdepth 1 -type f | wc -l) files; a refused name made one anyway"
+
+# ── An empty passphrase needs the explicit flag ─────────────────────────────
+
+reason="$(printf '\n' | runh generate emptypass 'no passphrase' | jq -r '.error // ""')"
+[[ -n "$reason" ]] || fail 'an empty passphrase was accepted without --no-passphrase'
+[[ ! -f "$ssh_dir/emptypass" ]] || fail 'a key was made with no passphrase and no explicit flag'
+
+# ── fix-permissions, confined and effective ─────────────────────────────────
+
+chmod 644 "$ssh_dir/contractkey"
+reason="$(runh fix-permissions contractkey | jq -r '.error // ""')"
+[[ -z "$reason" ]] || fail "fixing a key's permissions was refused: $reason"
+mode="$(stat -c '%a' "$ssh_dir/contractkey")"
+[[ "$mode" == "600" ]] || fail "fix-permissions left the key at $mode rather than 600"
+
+chmod 644 "$work/outside/target"
+for bad in '../outside/target' '/etc/hostname' '../../outside/target' 'sub/dir' ''; do
+ reason="$(runh fix-permissions "$bad" | jq -r '.error // ""')"
+ [[ -n "$reason" ]] || fail "fix-permissions accepted '$bad'"
+done
+[[ "$(stat -c '%a' "$work/outside/target")" == "644" ]] \
+ || fail 'fix-permissions chmodded a file outside the SSH directory'
+
+# ── Nothing reached the agent ───────────────────────────────────────────────
+
+grep -q '^ssh-add: ' "$log" \
+ && fail "the hermetic half invoked ssh-add: $(grep '^ssh-add: ' "$log")"
+
+printf 'ssh keys contract: PASS (passphrase over a pty only, names confined, nothing written to ~/.ssh)\n'