From 750755207ba222f2f2375c07490b1cd8f6c3b485 Mon Sep 17 00:00:00 2001 From: Sergio Costas Rodriguez Date: Thu, 23 Jul 2026 12:17:07 +0200 Subject: [PATCH 1/6] interfaces: Add orca-screen-reader interface To support a snapped Orca screen reader, a new interface is required to give to it full access to the accessibility bus. This patch adds a specific interface that allows the Orca screen reader to have full access to the required elements, and also allows any snapped program to send accessibility events to the screen reader. To simplify the design and guarantee backwards compatibility, the snap name for the screen reader is hardcoded. --- interfaces/builtin/desktop_legacy.go | 156 ++++++++++-------- interfaces/builtin/orca_screen_reader.go | 91 ++++++++++ interfaces/builtin/orca_screen_reader_test.go | 117 +++++++++++++ 3 files changed, 291 insertions(+), 73 deletions(-) create mode 100644 interfaces/builtin/orca_screen_reader.go create mode 100644 interfaces/builtin/orca_screen_reader_test.go diff --git a/interfaces/builtin/desktop_legacy.go b/interfaces/builtin/desktop_legacy.go index 724c91c3658..6803ad611d1 100644 --- a/interfaces/builtin/desktop_legacy.go +++ b/interfaces/builtin/desktop_legacy.go @@ -20,6 +20,8 @@ package builtin import ( + "strings" + "github.com/snapcore/snapd/interfaces" "github.com/snapcore/snapd/interfaces/apparmor" ) @@ -43,13 +45,6 @@ const desktopLegacyConnectedPlugAppArmor = ` # accessibility (a11y) #include -dbus (send) - bus=session - path=/org/a11y/bus - interface=org.a11y.Bus - member=GetAddress - peer=(label=unconfined), - #include # Allow access to the non-abstract D-Bus socket used by at-spi > 2.42.0 @@ -59,72 +54,6 @@ owner /{,var/}run/user/[0-9]*/at-spi/bus* rw, # Allow access to the socket used by speech-dispatcher owner /{,var/}run/user/[0-9]*/speech-dispatcher/speechd.sock rw, -# Allow the accessibility services in the user session to send us any events -dbus (receive) - bus=accessibility - peer=(label=unconfined), - -# Allow querying for capabilities and registering -dbus (send) - bus=accessibility - path="/org/a11y/atspi/accessible/root" - interface="org.a11y.atspi.Socket" - member="Embed" - peer=(name=org.a11y.atspi.Registry, label=unconfined), -dbus (send) - bus=accessibility - path="/org/a11y/atspi/registry" - interface="org.a11y.atspi.Registry" - member="GetRegisteredEvents" - peer=(name=org.a11y.atspi.Registry, label=unconfined), -dbus (send) - bus=accessibility - path="/org/a11y/atspi/registry/deviceeventcontroller" - interface="org.a11y.atspi.DeviceEventController" - member="Get{DeviceEvent,Keystroke}Listeners" - peer=(name=org.a11y.atspi.Registry, label=unconfined), -dbus (send) - bus=accessibility - path="/org/a11y/atspi/registry/deviceeventcontroller" - interface="org.a11y.atspi.DeviceEventController" - member="NotifyListenersSync" - peer=(name=org.a11y.atspi.Registry, label=unconfined), - -# org.a11y.atspi is not designed for application isolation and these rules -# can be used to send change events for other processes. -dbus (send) - bus=accessibility - path="/org/a11y/atspi/accessible/root" - interface="org.a11y.atspi.Event.Object" - member="ChildrenChanged" - peer=(name=org.freedesktop.DBus, label=unconfined), -dbus (send) - bus=accessibility - path="/org/a11y/atspi/accessible/root" - interface="org.a11y.atspi.Accessible" - member="Get*" - peer=(label=unconfined), -dbus (send) - bus=accessibility - path="/org/a11y/atspi/accessible/[0-9]*" - interface="org.a11y.atspi.Event.Object" - member="{ChildrenChanged,PropertyChange,StateChanged,TextCaretMoved}" - peer=(name=org.freedesktop.DBus, label=unconfined), -dbus (send) - bus=accessibility - path="/org/a11y/atspi/accessible/[0-9]*" - interface="org.freedesktop.DBus.Properties" - member="Get{,All}" - peer=(label=unconfined), - -dbus (send) - bus=accessibility - path="/org/a11y/atspi/cache" - interface="org.a11y.atspi.Cache" - member="{Add,Remove}Accessible" - peer=(name=org.freedesktop.DBus, label=unconfined), - - # ibus # subset of ibus abstraction /usr/lib/@{multiarch}/gtk-2.0/[0-9]*/immodules/im-ibus.so mr, @@ -404,6 +333,84 @@ dbus (send) peer=(name=org.freedesktop.DBus, label="{plasmashell,unconfined}"), ` +const accessibilityRules = ` +dbus (send) + bus=session + path=/org/a11y/bus + interface=org.a11y.Bus + member=GetAddress + peer=(label=###ACCESSIBILITY-LABEL###), + +# Allow the accessibility services in the user session to send us any events +dbus (receive) + bus=accessibility + peer=(label=###ACCESSIBILITY-LABEL###), + +# Allow querying for capabilities and registering +dbus (send) + bus=accessibility + path="/org/a11y/atspi/accessible/root" + interface="org.a11y.atspi.Socket" + member="Embed" + peer=(name=org.a11y.atspi.Registry, label=###ACCESSIBILITY-LABEL###), +dbus (send) + bus=accessibility + path="/org/a11y/atspi/registry" + interface="org.a11y.atspi.Registry" + member="GetRegisteredEvents" + peer=(name=org.a11y.atspi.Registry, label=###ACCESSIBILITY-LABEL###), +dbus (send) + bus=accessibility + path="/org/a11y/atspi/registry/deviceeventcontroller" + interface="org.a11y.atspi.DeviceEventController" + member="Get{DeviceEvent,Keystroke}Listeners" + peer=(name=org.a11y.atspi.Registry, label=###ACCESSIBILITY-LABEL###), +dbus (send) + bus=accessibility + path="/org/a11y/atspi/registry/deviceeventcontroller" + interface="org.a11y.atspi.DeviceEventController" + member="NotifyListenersSync" + peer=(name=org.a11y.atspi.Registry, label=###ACCESSIBILITY-LABEL###), + +# org.a11y.atspi is not designed for application isolation and these rules +# can be used to send change events for other processes. +dbus (send) + bus=accessibility + path="/org/a11y/atspi/accessible/root" + interface="org.a11y.atspi.Event.Object" + member="ChildrenChanged" + peer=(name=org.freedesktop.DBus, label=###ACCESSIBILITY-LABEL###), +dbus (send) + bus=accessibility + path="/org/a11y/atspi/accessible/root" + interface="org.a11y.atspi.Accessible" + member="Get*" + peer=(label=###ACCESSIBILITY-LABEL###), + +# For some reason, this path must be removed to allow snapped applications to work +# with snapped Orca, but not for unconfined Orca. +# path="/org/a11y/atspi/accessible/root" +dbus (send) + bus=accessibility + interface="org.a11y.atspi.Event.Object" + member="{ChildrenChanged,PropertyChange,StateChanged,TextCaretMoved}" + peer=(name=org.freedesktop.DBus, label=###ACCESSIBILITY-LABEL###), + +dbus (send) + bus=accessibility + path="/org/a11y/atspi/accessible/[0-9]*" + interface="org.freedesktop.DBus.Properties" + member="Get{,All}" + peer=(label=###ACCESSIBILITY-LABEL###), + +dbus (send) + bus=accessibility + path="/org/a11y/atspi/cache" + interface="org.a11y.atspi.Cache" + member="{Add,Remove}Accessible" + peer=(name=org.freedesktop.DBus, label=###ACCESSIBILITY-LABEL###), +` + const desktopLegacyConnectedPlugSecComp = ` # Description: Can access common desktop legacy methods. This gives privileged # access to the user's input. @@ -419,6 +426,9 @@ type desktopLegacyInterface struct { func (iface *desktopLegacyInterface) AppArmorConnectedPlug(spec *apparmor.Specification, plug *interfaces.ConnectedPlug, slot *interfaces.ConnectedSlot) error { spec.AddSnippet(desktopLegacyConnectedPlugAppArmor) + // Add accessibility rules both for unconfined and snap.orca.* labels, since the accessibility service may run under either label. + spec.AddSnippet(strings.Replace(accessibilityRules, "###ACCESSIBILITY-LABEL###", "unconfined", -1)) + spec.AddSnippet(strings.Replace(accessibilityRules, "###ACCESSIBILITY-LABEL###", "snap.orca.*", -1)) // the DesktopFileRules can conflict with the rules in other, more privileged, // interfaces (like desktop-launch), so they are added here with the minimum diff --git a/interfaces/builtin/orca_screen_reader.go b/interfaces/builtin/orca_screen_reader.go new file mode 100644 index 00000000000..261f59008af --- /dev/null +++ b/interfaces/builtin/orca_screen_reader.go @@ -0,0 +1,91 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2026 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package builtin + +import ( + "github.com/snapcore/snapd/interfaces" + "github.com/snapcore/snapd/interfaces/apparmor" + "github.com/snapcore/snapd/snap" +) + +const orcaScreenReaderSummary = `special permissions for Orca screen reader` + +const orcaScreenReaderBaseDeclarationSlots = ` + orca-screen-reader: + allow-installation: + slot-snap-type: + - app + - core + deny-connection: + on-classic: false + deny-auto-connection: true +` + +const orcaScreenReaderPlugAppArmor = ` +#include +#include + +network netlink, + +# full access to the accessibility bus +dbus (send, receive) + bus=accessibility, + +# full access to a11y elements in session bus +dbus (send, receive) + bus=session + path=/org/a11y/bus{,/**}, + +dbus (bind) + bus=session + name="org.gnome.Orca.Service", + +# allow access to the at-spi folder and +# the at-spi1-XXXXX folders +/run/user/[0-9]*/at-spi{,2-[0-9A-Z]*}/ rw, +/run/user/[0-9]*/at-spi{,2-[0-9A-Z]*}/** rwk, +` + +type orcaScreenReaderInterface struct{} + +func (iface *orcaScreenReaderInterface) Name() string { + return "orca-screen-reader" +} + +func (iface *orcaScreenReaderInterface) StaticInfo() interfaces.StaticInfo { + return interfaces.StaticInfo{ + Summary: orcaScreenReaderSummary, + ImplicitOnClassic: true, + BaseDeclarationSlots: orcaScreenReaderBaseDeclarationSlots, + } +} + +func (iface *orcaScreenReaderInterface) AppArmorConnectedPlug(spec *apparmor.Specification, plug *interfaces.ConnectedPlug, slot *interfaces.ConnectedSlot) error { + spec.AddSnippet(orcaScreenReaderPlugAppArmor) + return nil +} + +func (iface *orcaScreenReaderInterface) AutoConnect(*snap.PlugInfo, *snap.SlotInfo) bool { + return false +} + +func init() { + registerIface(&orcaScreenReaderInterface{}) +} diff --git a/interfaces/builtin/orca_screen_reader_test.go b/interfaces/builtin/orca_screen_reader_test.go new file mode 100644 index 00000000000..5515a99fcb1 --- /dev/null +++ b/interfaces/builtin/orca_screen_reader_test.go @@ -0,0 +1,117 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2026 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package builtin_test + +import ( + "strings" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/interfaces" + "github.com/snapcore/snapd/interfaces/apparmor" + "github.com/snapcore/snapd/interfaces/builtin" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/testutil" +) + +type AccessibilityLegacyInterfaceSuite struct { + iface interfaces.Interface + plugInfo *snap.PlugInfo + plug *interfaces.ConnectedPlug +} + +const accessibilityLegacyConsumerYaml = `name: consumer +version: 0 +apps: + app: + plugs: [orca-screen-reader] +` + +var _ = Suite(&AccessibilityLegacyInterfaceSuite{ + iface: builtin.MustInterface("orca-screen-reader"), +}) + +func (s *AccessibilityLegacyInterfaceSuite) SetUpTest(c *C) { + s.plug, s.plugInfo = MockConnectedPlug(c, accessibilityLegacyConsumerYaml, nil, "orca-screen-reader") +} + +func (s *AccessibilityLegacyInterfaceSuite) TestName(c *C) { + c.Assert(s.iface.Name(), Equals, "orca-screen-reader") +} + +// check if a string is part of any of the strings in a slice +func checkDBusRules(dbus_rules []string, matches []string) bool { + for _, rule := range dbus_rules { + found := 0 + for _, match := range matches { + if strings.HasSuffix(match, ",") { + match = strings.TrimSuffix(match, ",") + } + if strings.Contains(rule, match+"\n") { + found++ + } else { + found = -1 + break + } + } + if found == len(matches) { + return true + } + } + return false +} + +func getDBusRules(spec *apparmor.Specification) []string { + dbus_rules := strings.Split(spec.SnippetForTag("snap.consumer.app"), "dbus ") + out := []string{} + for _, rule := range dbus_rules { + out = append(out, strings.Split(rule, ",\n")[0]+"\n") + } + return out +} + +func (s *AccessibilityLegacyInterfaceSuite) TestAppArmorSpecForConnectedPlug(c *C) { + // connected plug to core slot + appSet, err := interfaces.NewSnapAppSet(s.plug.Snap(), nil) + c.Assert(err, IsNil) + spec := apparmor.NewSpecification(appSet) + c.Assert(spec.AddConnectedPlug(s.iface, s.plug, nil), IsNil) + + dbus_rules := getDBusRules(spec) + c.Assert(checkDBusRules(dbus_rules, []string{"(send, receive)", "bus=accessibility"}), Equals, true) + c.Assert(checkDBusRules(dbus_rules, []string{"(send, receive)", "bus=session", "path=/org/a11y/bus{,/**}"}), Equals, true) + c.Assert(checkDBusRules(dbus_rules, []string{"(bind)", "bus=session", "name=\"org.gnome.Orca.Service\""}), Equals, true) + + c.Assert(spec.SnippetForTag("snap.consumer.app"), testutil.Contains, "#include ") + c.Assert(spec.SnippetForTag("snap.consumer.app"), testutil.Contains, "/run/user/[0-9]*/at-spi{,2-[0-9A-Z]*}/ rw,") +} + +func (s *AccessibilityLegacyInterfaceSuite) TestAppArmorSpecForNotConnectedPlug(c *C) { + appSet, err := interfaces.NewSnapAppSet(s.plug.Snap(), nil) + c.Assert(err, IsNil) + spec := apparmor.NewSpecification(appSet) + dbus_rules := getDBusRules(spec) + c.Assert(checkDBusRules(dbus_rules, []string{"(send, receive)", "bus=accessibility"}), Equals, false) + c.Assert(checkDBusRules(dbus_rules, []string{"(send, receive)", "bus=session", "path=/org/a11y/bus{,/**}"}), Equals, false) + c.Assert(checkDBusRules(dbus_rules, []string{"(bind)", "bus=session", "name=\"org.gnome.Orca.Service\""}), Equals, false) + + c.Assert(spec.SnippetForTag("snap.consumer.app"), Not(testutil.Contains), "#include ") + c.Assert(spec.SnippetForTag("snap.consumer.app"), Not(testutil.Contains), "/run/user/[0-9]*/at-spi{,2-[0-9A-Z]*}/ rw,") +} From d3e52e8e6a6adba652b6cd5822d9423ed70d44e8 Mon Sep 17 00:00:00 2001 From: Sergio Costas Rodriguez Date: Thu, 23 Jul 2026 12:30:58 +0200 Subject: [PATCH 2/6] Add test --- tests/lib/snaps/test-snapd-policy-app-consumer/meta/snap.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/lib/snaps/test-snapd-policy-app-consumer/meta/snap.yaml b/tests/lib/snaps/test-snapd-policy-app-consumer/meta/snap.yaml index a51baf39f63..b9891cb4729 100644 --- a/tests/lib/snaps/test-snapd-policy-app-consumer/meta/snap.yaml +++ b/tests/lib/snaps/test-snapd-policy-app-consumer/meta/snap.yaml @@ -191,6 +191,9 @@ apps: optical-drive: command: bin/run plugs: [ optical-drive ] + orca-screen-reader: + command: bin/run + plugs: [ orca-screen-reader ] pcscd: command: bin/run plugs: [ pcscd ] From 5f0b7529fe672cb63054cc7edef1cefb804540b3 Mon Sep 17 00:00:00 2001 From: Sergio Costas Rodriguez Date: Thu, 23 Jul 2026 12:56:52 +0200 Subject: [PATCH 3/6] Add test --- interfaces/policy/basedeclaration_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/interfaces/policy/basedeclaration_test.go b/interfaces/policy/basedeclaration_test.go index 46d853cd195..0d647acea9b 100644 --- a/interfaces/policy/basedeclaration_test.go +++ b/interfaces/policy/basedeclaration_test.go @@ -854,6 +854,7 @@ var ( "nvme-control": {"core"}, "ofono": {"app", "core"}, "online-accounts-service": {"app"}, + "orca-screen-reader": {"app"}, "power-control": {"core"}, "ppp": {"core"}, "polkit-agent": {"core"}, From b6c5d685627036065413b38074d7b3f98a4c7f90 Mon Sep 17 00:00:00 2001 From: Sergio Costas Rodriguez Date: Thu, 23 Jul 2026 13:23:47 +0200 Subject: [PATCH 4/6] Can be only a core --- interfaces/policy/basedeclaration_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/interfaces/policy/basedeclaration_test.go b/interfaces/policy/basedeclaration_test.go index 0d647acea9b..0f423d9d4d8 100644 --- a/interfaces/policy/basedeclaration_test.go +++ b/interfaces/policy/basedeclaration_test.go @@ -854,7 +854,7 @@ var ( "nvme-control": {"core"}, "ofono": {"app", "core"}, "online-accounts-service": {"app"}, - "orca-screen-reader": {"app"}, + "orca-screen-reader": {"core"}, "power-control": {"core"}, "ppp": {"core"}, "polkit-agent": {"core"}, From 263d8dc93728928035272080285e88e2e2275087 Mon Sep 17 00:00:00 2001 From: Sergio Costas Rodriguez Date: Thu, 23 Jul 2026 13:42:37 +0200 Subject: [PATCH 5/6] Fix autoconnection --- interfaces/builtin/orca_screen_reader.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/interfaces/builtin/orca_screen_reader.go b/interfaces/builtin/orca_screen_reader.go index 261f59008af..cb04ace635a 100644 --- a/interfaces/builtin/orca_screen_reader.go +++ b/interfaces/builtin/orca_screen_reader.go @@ -83,7 +83,8 @@ func (iface *orcaScreenReaderInterface) AppArmorConnectedPlug(spec *apparmor.Spe } func (iface *orcaScreenReaderInterface) AutoConnect(*snap.PlugInfo, *snap.SlotInfo) bool { - return false + // allow what declarations allowed + return true } func init() { From c4831f35a37851b9388ee8ef0e34181fe7b0731f Mon Sep 17 00:00:00 2001 From: Sergio Costas Rodriguez Date: Thu, 23 Jul 2026 14:01:51 +0200 Subject: [PATCH 6/6] Fix slot --- interfaces/builtin/orca_screen_reader.go | 1 - 1 file changed, 1 deletion(-) diff --git a/interfaces/builtin/orca_screen_reader.go b/interfaces/builtin/orca_screen_reader.go index cb04ace635a..60eb061d8f0 100644 --- a/interfaces/builtin/orca_screen_reader.go +++ b/interfaces/builtin/orca_screen_reader.go @@ -31,7 +31,6 @@ const orcaScreenReaderBaseDeclarationSlots = ` orca-screen-reader: allow-installation: slot-snap-type: - - app - core deny-connection: on-classic: false