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