voice-limit-guard: live revoke never fired — LiveKit's JSON uses snake_case permissions
Lint / Shell (shellcheck) (push) Successful in 9s
Lint / JS (eslint) (push) Successful in 6s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Successful in 1m13s
Lint / Secret scan (gitleaks) (push) Successful in 5s

livekit-server (verified on 1.13.7) serialises ParticipantPermission as
can_publish / can_publish_sources; the reconciler read canPublish /
canPublishSources, saw 'publishes nothing' for everyone and never called
UpdateParticipant, so turning Allow Screen Sharing off did not stop an
in-progress share. Normalise the permission keys before deciding.
Verified on a local Synapse + LiveKit + guard stack: share track gone
from the SFU 2 s after the policy flip. Tests added (44 pass).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-18 22:10:14 -04:00
co-authored by Claude Opus 5
parent 99bc15c6f0
commit ee5f78b71e
2 changed files with 45 additions and 1 deletions
+27
View File
@@ -362,3 +362,30 @@ class TestRoomStateParsing(unittest.TestCase):
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
class NormalizePermissionTest(unittest.TestCase):
"""livekit-server's JSON uses proto names (snake_case); the reconciler must
not read camelCase and conclude nobody publishes (which silently disabled
the live screenshare kill)."""
def test_snake_case_permission_is_reconciled(self):
calls = []
guard.livekit_update_participant = lambda alias, identity, perm: calls.append((identity, perm))
participant = {
"identity": "@a:x:DEV",
"permission": {"can_subscribe": True, "can_publish": True, "can_publish_data": True, "can_publish_sources": []},
}
changed = guard.reconcile_participant("!r:x", participant, {"SCREEN_SHARE", "SCREEN_SHARE_AUDIO"})
self.assertTrue(changed)
identity, perm = calls[0]
self.assertEqual(identity, "@a:x:DEV")
self.assertTrue(perm["canPublish"])
self.assertNotIn("SCREEN_SHARE", perm["canPublishSources"])
self.assertIn("MICROPHONE", perm["canPublishSources"])
self.assertTrue(perm["canSubscribe"]) # preserved
def test_camel_case_still_works(self):
guard.livekit_update_participant = lambda *a: None
participant = {"identity": "@a:x:DEV", "permission": {"canPublish": True, "canPublishSources": ["CAMERA"]}}
self.assertFalse(guard.reconcile_participant("!r:x", participant, {"SCREEN_SHARE"}))
+18 -1
View File
@@ -399,10 +399,27 @@ def reconcile_publish_sources(current, forbidden: set):
return sorted(effective - forbidden) return sorted(effective - forbidden)
def _camel(key: str) -> str:
head, *rest = key.split("_")
return head + "".join(part.capitalize() for part in rest)
def normalize_permission(perm: dict) -> dict:
"""LiveKit's Twirp JSON serialises ParticipantPermission with proto field
names (`can_publish`, `can_publish_sources`, ...) — verified against
livekit-server 1.13 — while the JWT grant and older docs use camelCase.
Return a camelCase copy so the policy code reads one shape. (protojson
accepts either spelling on input, so the copy we send back is fine.)"""
out = {}
for key, value in (perm or {}).items():
out[_camel(key) if "_" in key else key] = value
return out
def reconcile_participant(alias: str, participant: dict, forbidden: set) -> bool: def reconcile_participant(alias: str, participant: dict, forbidden: set) -> bool:
"""Enforce the forbidden-source policy on one live participant. Returns True """Enforce the forbidden-source policy on one live participant. Returns True
if an UpdateParticipant call was issued.""" if an UpdateParticipant call was issued."""
perm = participant.get("permission") or {} perm = normalize_permission(participant.get("permission") or {})
if not perm.get("canPublish", False): if not perm.get("canPublish", False):
return False # publishes nothing -> nothing to revoke return False # publishes nothing -> nothing to revoke
current = perm.get("canPublishSources") or [] current = perm.get("canPublishSources") or []