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

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 19:26:56 -04:00
parent 4ec8bd94d9
commit 6f0ce639d9
25 changed files with 3622 additions and 408 deletions
+18
View File
@@ -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
@@ -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
+412 -62
View File
@@ -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 <token>` 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'
+354
View File
@@ -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'
<?xml version="1.0" encoding="UTF-8"?>
<xbel version="1.0"
xmlns:bookmark="http://www.freedesktop.org/standards/desktop-bookmarks"
xmlns:mime="http://www.freedesktop.org/standards/shared-mime-info">
<bookmark href="file:///home/fixture/one.txt" added="2026-01-01T00:00:00Z"
modified="2026-01-01T00:00:00Z" visited="2026-01-01T00:00:00Z">
<info><metadata owner="http://freedesktop.org">
<mime:mime-type type="text/plain"/>
</metadata></info>
</bookmark>
<bookmark href="file:///home/fixture/two.txt" added="2026-01-02T00:00:00Z"
modified="2026-01-02T00:00:00Z" visited="2026-01-02T00:00:00Z">
<info><metadata owner="http://freedesktop.org">
<mime:mime-type type="text/plain"/>
</metadata></info>
</bookmark>
</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 <xbel>")
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'
+71 -2
View File
@@ -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'
+6 -4
View File
@@ -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"
+367 -18
View File
@@ -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" <<STUB
#!/usr/bin/env bash
log="$log"
{
printf 'argv: %s\n' "\$(tr '\\0' ' ' </proc/\$\$/cmdline)"
printf 'environ: %s\n' "\$(tr '\\0' ' ' </proc/\$\$/environ)"
} >>"\$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" <<STUB
#!/usr/bin/env bash
printf 'ssh-add: %s\n' "\$*" >>"$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'