Skip to content

Commit ef3a64a

Browse files
JarbasAlclaude
andcommitted
fix: resolve skill_id from bus context in spec and legacy detach handlers
handle_register_template, handle_register_entity, handle_deregister_intent, handle_deregister_entity, handle_deregister_skill and the legacy handle_detach_skill trusted message.data['skill_id'] unconditionally, so a payload disagreeing with message.context['skill_id'] silently acted under another skill's name. The bus client twins ovos.skill.deregister into the legacy detach_skill topic carrying the original context, so guarding only the spec handler left the legacy twin as a bypass: on FakeBus with ovos-bus-client 2.11.14a2, ovos.skill.deregister with data.skill_id=victim and context.skill_id=attacker still removed the victim's intents. Add module-level _skill_id_from_context(message, handler): when context carries a skill_id it is used regardless of the payload (a mismatch is logged at WARNING and never acted on); when context carries no skill_id the payload value is trusted, which is where this helper diverges from the ovos_adapt and ovos_padatious twins (those return None). Wire it into the five spec handlers and into handle_detach_skill. The legacy handle_detach_intent, twinned from ovos.intent.deregister with the producer's context, compares the intent_name owner prefix against context.skill_id and rejects a mismatch, with the same guard condition as ovos_adapt and ovos_padatious: a truthy context skill_id AND a namespaced intent_name. A bare unnamespaced name or an empty-string context keeps the pre-spec detach. Fail-before against origin/dev (tests kept, source reverted): 4 failed / 9 passed of the detach/deregister set; the bus-emitted deregister case fails with [] != ['victim.skill:play_music']. After: 13 passed. Full suite 109 passed, 12 failed (on top of padacioso#102, whose prefix match handle_detach_skill keeps); origin/dev in the same venv is 96 passed, 12 failed with the identical failed set (ovoscope register_padatious_intent skill_id kwarg drift, tracked in padacioso#97). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 98e0f2c commit ef3a64a

2 files changed

Lines changed: 196 additions & 7 deletions

File tree

padacioso/opm.py

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -280,10 +280,27 @@ def __detach_intent(self, intent_name, lang=None):
280280
def handle_detach_intent(self, message):
281281
"""Messagebus handler for detaching padacioso intent.
282282
283+
The bus-client twins ``ovos.intent.deregister`` into this legacy
284+
topic, building ``intent_name`` (``<skill_id>:<name>``) from the
285+
payload while forwarding the producer's original context, so the
286+
prefix is compared against ``context["skill_id"]`` and a mismatch
287+
is rejected. A bare, unnamespaced name or a message without a
288+
context skill_id keeps the pre-spec behaviour.
289+
283290
Args:
284291
message (Message): message triggering action
285292
"""
286-
self.__detach_intent(message.data.get('intent_name'))
293+
intent_name = message.data.get('intent_name')
294+
context_skill_id = message.context.get("skill_id") if message.context else None
295+
if context_skill_id and intent_name and ":" in intent_name:
296+
owner = intent_name.split(":", 1)[0]
297+
if owner != context_skill_id:
298+
LOG.warning(f"[handle_detach_intent] rejected: intent_name="
299+
f"{intent_name!r} does not belong to "
300+
f"message.context['skill_id']={context_skill_id!r} "
301+
f"(topic={message.msg_type})")
302+
return
303+
self.__detach_intent(intent_name)
287304

288305
def __detach_entity(self, name, lang):
289306
""" Remove an entity.
@@ -300,10 +317,16 @@ def __detach_entity(self, name, lang):
300317
def handle_detach_skill(self, message):
301318
"""Messagebus handler for detaching all intents for skill.
302319
320+
The bus-client twins ``ovos.skill.deregister`` into this legacy
321+
topic with the producer's original context, so the skill_id is
322+
resolved the same way as in ``handle_deregister_skill``.
323+
303324
Args:
304325
message (Message): message triggering action
305326
"""
306-
skill_id = message.data['skill_id']
327+
skill_id = _skill_id_from_context(message, "handle_detach_skill")
328+
if not skill_id:
329+
return
307330
prefix = skill_id + ":"
308331
remove_list = [i for i in self.registered_intents if i.startswith(prefix)]
309332
for i in remove_list:
@@ -450,7 +473,7 @@ def handle_register_template(self, message: Message):
450473
"""
451474
topic = SpecMessage.INTENT_REGISTER_TEMPLATE.value
452475
data = message.data
453-
skill_id = data.get("skill_id")
476+
skill_id = _skill_id_from_context(message, "handle_register_template")
454477
intent_name = data.get("intent_name")
455478
samples = data.get("samples")
456479
if not samples: # §6.3 malformed
@@ -494,7 +517,7 @@ def handle_register_entity(self, message: Message):
494517
"""OVOS-INTENT-4 §7 — register an entity value-set hint."""
495518
topic = SpecMessage.ENTITY_REGISTER.value
496519
data = message.data
497-
skill_id = data.get("skill_id")
520+
skill_id = _skill_id_from_context(message, "handle_register_entity")
498521
entity_name = data.get("entity_name")
499522
samples = data.get("samples")
500523
if not samples: # §7.2 malformed
@@ -535,7 +558,7 @@ def _intent_langs(self, message: Message) -> List[str]:
535558

536559
def handle_deregister_intent(self, message: Message):
537560
"""OVOS-INTENT-4 §8.2 — remove one intent (all langs if lang omitted)."""
538-
skill_id = message.data.get("skill_id")
561+
skill_id = _skill_id_from_context(message, "handle_deregister_intent")
539562
intent_name = message.data.get("intent_name")
540563
name = self._internal_name(skill_id, intent_name)
541564
self.__detach_intent(name)
@@ -544,7 +567,7 @@ def handle_deregister_intent(self, message: Message):
544567

545568
def handle_deregister_entity(self, message: Message):
546569
"""OVOS-INTENT-4 §8.3 — remove one entity (all langs if lang omitted)."""
547-
skill_id = message.data.get("skill_id")
570+
skill_id = _skill_id_from_context(message, "handle_deregister_entity")
548571
entity_name = message.data.get("entity_name")
549572
name = self._internal_name(skill_id, entity_name)
550573
for lang in self._intent_langs(message):
@@ -554,7 +577,7 @@ def handle_deregister_entity(self, message: Message):
554577

555578
def handle_deregister_skill(self, message: Message):
556579
"""OVOS-INTENT-4 §8.4 — remove everything owned by a skill_id."""
557-
skill_id = message.data.get("skill_id")
580+
skill_id = _skill_id_from_context(message, "handle_deregister_skill")
558581
if not skill_id:
559582
return
560583
prefix = skill_id + ":"
@@ -731,6 +754,36 @@ def shutdown(self):
731754
self.bus.remove(SpecMessage.INTENT_DISABLE.value, self.handle_disable_intent)
732755

733756

757+
def _skill_id_from_context(message: Message, handler: str) -> Optional[str]:
758+
"""Resolve the skill id a handler acts on from the bus context.
759+
760+
When ``message.context["skill_id"]`` is set it is used, and a payload
761+
``skill_id`` that differs from it is logged and ignored, so the handler
762+
proceeds under the context's name. This matches ``ovos_adapt`` and
763+
``ovos_padatious``. When the context carries no skill_id, the payload
764+
value is trusted; here the helper diverges from those twins, which
765+
return ``None`` in that case.
766+
767+
Args:
768+
message: the incoming bus message
769+
handler: name of the calling handler, for the warning message
770+
771+
Returns:
772+
The skill id from the context, or the payload's when the context
773+
carries none.
774+
"""
775+
context_skill_id = message.context.get("skill_id") if message.context else None
776+
payload_skill_id = message.data.get("skill_id")
777+
if context_skill_id is None:
778+
return payload_skill_id
779+
if payload_skill_id and payload_skill_id != context_skill_id:
780+
LOG.warning(f"[{handler}] message.data['skill_id']={payload_skill_id!r} "
781+
f"differs from message.context['skill_id']="
782+
f"{context_skill_id!r} on {message.msg_type}; "
783+
f"using the context value")
784+
return context_skill_id
785+
786+
734787
def _dealias_intent_name(name: Optional[str]) -> Optional[str]:
735788
"""Fold the legacy ``<skill_id>:<file>.intent`` id onto the OVOS-INTENT-4
736789
canonical ``<skill_id>:<file>`` id.

test/test_pipeline.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,142 @@ def test_legacy_still_works(self):
217217
intent = svc.calc_intent("hello there", "en-US")
218218
self.assertEqual(intent.name, "greet.skill:hello")
219219

220+
def test_mismatched_register_template_uses_context_skill_id(self):
221+
# OVOS-INTENT-4 §3.2 — message.context is attached by the bus client
222+
# and cannot be forged by a producer, so it is authoritative: a
223+
# payload skill_id that disagrees with it is logged and ignored, and
224+
# the registration proceeds under the CONTEXT skill_id (matches
225+
# ovos_adapt/opm.py:73 and ovos_padatious's twin helper)
226+
svc = self.get_service()
227+
msg = Message(self.SpecMessage.INTENT_REGISTER_TEMPLATE.value, {
228+
"skill_id": "payload.skill",
229+
"intent_name": "play_music",
230+
"lang": "en-US",
231+
"samples": ["play {query}"],
232+
}, {"skill_id": "context.skill"})
233+
svc.handle_register_template(msg)
234+
self.assertIn("context.skill:play_music",
235+
svc.containers["en-US"].intent_samples)
236+
self.assertNotIn("payload.skill:play_music",
237+
svc.containers["en-US"].intent_samples)
238+
239+
def test_mismatched_deregister_skill_targets_context_skill_id(self):
240+
# the resolved skill_id (context, not payload) is what gets
241+
# deregistered: a mismatched ovos.skill.deregister acts on the
242+
# CONTEXT skill's intents, leaving the payload-named skill's own
243+
# intents untouched
244+
svc = self.get_service()
245+
svc.handle_register_template(Message(
246+
self.SpecMessage.INTENT_REGISTER_TEMPLATE.value, {
247+
"skill_id": "victim.skill", "intent_name": "play_music",
248+
"lang": "en-US", "samples": ["play {query}"],
249+
}))
250+
svc.handle_deregister_skill(Message(
251+
self.SpecMessage.SKILL_DEREGISTER.value,
252+
{"skill_id": "victim.skill"},
253+
{"skill_id": "attacker.skill"}))
254+
self.assertIn("victim.skill:play_music",
255+
svc.containers["en-US"].intent_samples)
256+
intent = svc.calc_intent("play jazz", "en-US")
257+
self.assertEqual(intent.name, "victim.skill:play_music")
258+
259+
def _register_victim(self, svc):
260+
svc.bus.emit(Message(
261+
self.SpecMessage.INTENT_REGISTER_TEMPLATE.value, {
262+
"skill_id": "victim.skill", "intent_name": "play_music",
263+
"lang": "en-US", "samples": ["play {query}"],
264+
}, {"skill_id": "victim.skill"}))
265+
self.assertIn("victim.skill:play_music",
266+
svc.containers["en-US"].intent_samples)
267+
268+
def test_bus_emitted_mismatched_deregister_skill_keeps_victim_intents(self):
269+
# the bus client twins ovos.skill.deregister into the legacy
270+
# detach_skill topic with the ORIGINAL context, so the legacy
271+
# handler must resolve skill_id from context too: an attacker
272+
# naming the victim in the payload leaves the victim's roster intact
273+
svc = self.get_service()
274+
self._register_victim(svc)
275+
svc.bus.emit(Message(self.SpecMessage.SKILL_DEREGISTER.value,
276+
{"skill_id": "victim.skill"},
277+
{"skill_id": "attacker.skill"}))
278+
self.assertEqual(list(svc.containers["en-US"].intent_samples),
279+
["victim.skill:play_music"])
280+
self.assertIn("victim.skill:play_music", svc.registered_intents)
281+
intent = svc.calc_intent("play jazz", "en-US")
282+
self.assertEqual(intent.name, "victim.skill:play_music")
283+
284+
def test_legacy_detach_skill_mismatched_context_keeps_victim_intents(self):
285+
svc = self.get_service()
286+
self._register_victim(svc)
287+
svc.bus.emit(Message("detach_skill", {"skill_id": "victim.skill"},
288+
{"skill_id": "attacker.skill"}))
289+
self.assertEqual(list(svc.containers["en-US"].intent_samples),
290+
["victim.skill:play_music"])
291+
292+
def test_legacy_detach_skill_matching_context_removes_intents(self):
293+
svc = self.get_service()
294+
self._register_victim(svc)
295+
svc.bus.emit(Message("detach_skill", {"skill_id": "victim.skill"},
296+
{"skill_id": "victim.skill"}))
297+
self.assertEqual(list(svc.containers["en-US"].intent_samples), [])
298+
self.assertNotIn("victim.skill:play_music", svc.registered_intents)
299+
300+
def test_legacy_detach_skill_without_context_keeps_legacy_behaviour(self):
301+
svc = self.get_service()
302+
self._register_victim(svc)
303+
svc.bus.emit(Message("detach_skill", {"skill_id": "victim.skill"}))
304+
self.assertEqual(list(svc.containers["en-US"].intent_samples), [])
305+
306+
def test_detach_intent_forged_prefix_is_rejected(self):
307+
# legacy detach_intent carries the owner only as the intent_name
308+
# prefix; a producer under another context must not detach it
309+
svc = self.get_service()
310+
self._register_victim(svc)
311+
svc.bus.emit(Message("detach_intent",
312+
{"intent_name": "victim.skill:play_music"},
313+
{"skill_id": "attacker.skill"}))
314+
self.assertEqual(list(svc.containers["en-US"].intent_samples),
315+
["victim.skill:play_music"])
316+
self.assertIn("victim.skill:play_music", svc.registered_intents)
317+
intent = svc.calc_intent("play jazz", "en-US")
318+
self.assertEqual(intent.name, "victim.skill:play_music")
319+
320+
def test_detach_intent_matching_prefix_detaches(self):
321+
svc = self.get_service()
322+
self._register_victim(svc)
323+
svc.bus.emit(Message("detach_intent",
324+
{"intent_name": "victim.skill:play_music"},
325+
{"skill_id": "victim.skill"}))
326+
self.assertEqual(list(svc.containers["en-US"].intent_samples), [])
327+
self.assertNotIn("victim.skill:play_music", svc.registered_intents)
328+
329+
def test_detach_intent_without_context_keeps_legacy_behaviour(self):
330+
svc = self.get_service()
331+
self._register_victim(svc)
332+
svc.bus.emit(Message("detach_intent",
333+
{"intent_name": "victim.skill:play_music"}))
334+
self.assertEqual(list(svc.containers["en-US"].intent_samples), [])
335+
336+
def test_detach_intent_bare_name_with_context_keeps_legacy_behaviour(self):
337+
# an unnamespaced legacy name has no owner prefix to compare, so the
338+
# guard does not apply (parity with ovos_adapt and ovos_padatious)
339+
svc = self.get_service()
340+
svc.bus.emit(Message("padatious:register_intent", {
341+
"name": "on", "samples": ["turn on the {thing}"],
342+
"lang": "en-US"}, {"skill_id": "victim.skill"}))
343+
self.assertIn("on", svc.containers["en-US"].intent_samples)
344+
svc.bus.emit(Message("detach_intent", {"intent_name": "on"},
345+
{"skill_id": "victim.skill"}))
346+
self.assertNotIn("on", svc.containers["en-US"].intent_samples)
347+
348+
def test_detach_intent_empty_context_skill_id_keeps_legacy_behaviour(self):
349+
svc = self.get_service()
350+
self._register_victim(svc)
351+
svc.bus.emit(Message("detach_intent",
352+
{"intent_name": "victim.skill:play_music"},
353+
{"skill_id": ""}))
354+
self.assertEqual(list(svc.containers["en-US"].intent_samples), [])
355+
220356

221357
class ContextGatingTest(unittest.TestCase):
222358
"""OVOS-CONTEXT-1 §6/§6.1 requires_context / excludes_context gating."""

0 commit comments

Comments
 (0)