#!/usr/bin/env bash # Application permissions, as far as the desktop can actually enforce them. # # The rules: # # 1. The page never claims more than the portal can do. A native binary opens # /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 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 # 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. # # 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 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" qml_dir="$repo_dir/config/dot/quickshell" fail() { printf 'permissions contract: %s\n' "$1" >&2 exit 1 } 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; } 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' # ── 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. 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 path = sys.argv[1] source = open(path, encoding="utf-8").read() tree = ast.parse(source) # 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) structured = {"screencast", "remote-desktop"} 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() 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} 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") # 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'