Repository navigation
fix(bridge): run say/start-story through the kid-mode content filter - #146
Merged
Merged
Conversation
Audit finding (kid-safety). The dashboard `/actions/say` and `/actions/start-story` ingresses sanitised control chars + length but never called `content_filter()` — the same filter that guards the voice path. So with kid-mode on, an operator (or any LAN client when dashboard auth is unset) could make Dotty speak arbitrary unfiltered text, and those turns never populated the safety ring. Add `_kid_blocked(text)`: when kid-mode is on (read via the registered kid_mode_getter, defaulting ON when unconfigured — fail safe), run the sanitised text through the shared `content_filter()`; the hit is logged/metered/ring- recorded inside it. Both handlers now refuse blocked text with a clear message; start-story refuses BEFORE flipping into story_time. Tests: new tests/test_dashboard_say_filter.py (6 cases) — helper behaviour (kid on/off/unconfigured), say blocked/clean/kid-off, and start-story block (no state flip) vs clean (flips + injects). Handlers invoked directly so CSRF isn't in the path; content_filter + _inject_or_error stubbed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR addresses a kid-safety audit finding by ensuring the dashboard action endpoints that can inject text into the device (/actions/say and /actions/start-story) are subject to the same kid-mode content_filter() enforcement as the voice path, and that blocked attempts are refused before any state transitions.
Changes:
- Added a shared
_kid_blocked(text)helper inbridge/dashboard.pythat fail-safes to kid-mode ON when unconfigured or when the getter errors, and runs text throughbridge.text.content_filter(). - Wired
_kid_blocked()into thesayandstart_storydashboard handlers, returning a clear refusal message when blocked (and blockingstart_storybefore flipping intostory_time). - Added a focused unit test suite covering helper semantics and handler behavior for blocked/clean text and kid-mode on/off/unconfigured.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| bridge/dashboard.py | Adds _kid_blocked() and applies it to say/start_story to enforce kid-mode filtering on dashboard ingress. |
| tests/test_dashboard_say_filter.py | Adds deterministic unit tests validating filter enforcement and start_story state-flip behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+716
to
+720
| getter = _state.get("kid_mode_getter") | ||
| try: | ||
| kid_on = bool(getter()) if getter else True | ||
| except Exception: | ||
| kid_on = True |
This was referenced Jun 6, 2026
BrettKinny
added a commit
that referenced
this pull request
Jun 11, 2026
…157) Lift the pure blocked-content core (three tier regexes + the kid-safe replacement) out of bridge/text.py into the shared custom-providers/textUtils.py — already the canonical safety-constants home both containers import — and add content_filter_match() plus a sentence-buffered filter_tts_stream() wrapper. The bridge keeps its metrics/safety-ring/logging wrapper on top, behaviour unchanged. Both live providers now run TTS-bound output through the filter, gated on kid_mode: - streaming decision (per the issue's open question): buffer to _SENTENCE_BOUNDARY — the tier patterns are single-word regexes, so a blocked term can never straddle a sentence boundary; nothing reaches TTS before its sentence is checked, and spoken latency is unchanged (the TTS layer buffers to sentences anyway) - on a hit the rest of the turn is replaced with the cheerful redirect; mid-turn replacements drop the redirect's leading emoji to preserve the one-emoji-per-reply firmware contract - kid_mode off is a transparent passthrough (zero behaviour change) - OpenAICompat's emoji-prefix enforcement runs before the filter, so the leading-glyph contract survives filtering Tests: tests/test_voice_content_filter.py (16 cases — core matcher, stream wrapper incl. chunk-straddling terms, bridge wrapper parity, and both providers hit/clean/kid-off, mirroring the #146 pattern); plus a guard that the tier regexes exist in only one file. docs/faq.md updated to describe the filter honestly as a weak, bypassable backstop. Still open on #157 before release sign-off: on-device red-team verification (bench-pending). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
BrettKinny
added a commit
that referenced
this pull request
Jun 11, 2026
…ADMIN_TOKEN end-to-end (#158) * feat(auth): plumb DOTTY_ADMIN_TOKEN end-to-end (env, compose, setup, docs) The X-Admin-Token chain (#149-#152) shipped code-complete but operationally dead: five services read DOTTY_ADMIN_TOKEN, zero config surfaces provided it, so every deploy silently stayed permissive and the admin routes remained unauthenticated. - make setup: create .env from .env.example if missing; generate a 32-byte hex token when no DOTTY_ADMIN_TOKEN line exists (idempotent; an existing line — even deliberately empty — is respected) - docker-compose.yml.template + compose.all-in-one.yml: pass ${DOTTY_ADMIN_TOKEN:-} to xiaozhi-server - dotty-pi/docker-compose.yml: add optional env_file (the container had no env mechanism at all, so adminFetch could never receive the token) - bridge/ + dotty-behaviour/ compose: document the token among the deploy-dir .env secrets - .env.example + SETUP.md §10: enable-everywhere-or-nowhere semantics - CHANGELOG: record the #149-#152 chain (previously absent) + this fix Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(kid-mode): wire shared content_filter into the live voice path (#157) Lift the pure blocked-content core (three tier regexes + the kid-safe replacement) out of bridge/text.py into the shared custom-providers/textUtils.py — already the canonical safety-constants home both containers import — and add content_filter_match() plus a sentence-buffered filter_tts_stream() wrapper. The bridge keeps its metrics/safety-ring/logging wrapper on top, behaviour unchanged. Both live providers now run TTS-bound output through the filter, gated on kid_mode: - streaming decision (per the issue's open question): buffer to _SENTENCE_BOUNDARY — the tier patterns are single-word regexes, so a blocked term can never straddle a sentence boundary; nothing reaches TTS before its sentence is checked, and spoken latency is unchanged (the TTS layer buffers to sentences anyway) - on a hit the rest of the turn is replaced with the cheerful redirect; mid-turn replacements drop the redirect's leading emoji to preserve the one-emoji-per-reply firmware contract - kid_mode off is a transparent passthrough (zero behaviour change) - OpenAICompat's emoji-prefix enforcement runs before the filter, so the leading-glyph contract survives filtering Tests: tests/test_voice_content_filter.py (16 cases — core matcher, stream wrapper incl. chunk-straddling terms, bridge wrapper parity, and both providers hit/clean/kid-off, mirroring the #146 pattern); plus a guard that the tier regexes exist in only one file. docs/faq.md updated to describe the filter honestly as a weak, bypassable backstop. Still open on #157 before release sign-off: on-device red-team verification (bench-pending). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Audit finding (kid-safety). Off
main.Bug
The dashboard
/actions/sayand/actions/start-storyingresses sanitised control chars + length but never calledcontent_filter()— the same filter that guards the voice path. So with kid-mode on, an operator (or any LAN client when dashboard auth is unset) could make Dotty speak arbitrary unfiltered text, and those turns never populated the safety ring.Fix
New
_kid_blocked(text): when kid-mode is on (read via the registeredkid_mode_getter, defaulting ON when unconfigured — fail safe), run the sanitised text through the sharedcontent_filter(); the hit is logged/metered/ring-recorded inside it. Both handlers refuse blocked text with a clear message;start-storyrefuses before flipping intostory_time.Tests
New
tests/test_dashboard_say_filter.py(6 cases): helper behaviour (kid on/off/unconfigured fail-safe), say blocked/clean/kid-off, start-story block (no state flip) vs clean (flips + injects). Handlers invoked directly so CSRF isn't in the path;content_filter+_inject_or_errorstubbed for determinism. Fulltests/suite passes; ruff clean.Deploys via the bridge container (docker build + recreate).
🤖 Generated with Claude Code