Skip to content

fix(bridge): run say/start-story through the kid-mode content filter - #146

Merged
BrettKinny merged 1 commit into
mainfrom
audit/bridge-say-kid-filter
Jun 5, 2026
Merged

BrettKinny merged 1 commit into
mainfrom
audit/bridge-say-kid-filter

Conversation

@BrettKinny

Copy link
Copy Markdown
Owner

Audit finding (kid-safety). Off main.

Bug

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.

Fix

New _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 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 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_error stubbed for determinism. Full tests/ suite passes; ruff clean.

Deploys via the bridge container (docker build + recreate).

🤖 Generated with Claude Code

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>
Copilot AI review requested due to automatic review settings June 5, 2026 10:05

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 in bridge/dashboard.py that fail-safes to kid-mode ON when unconfigured or when the getter errors, and runs text through bridge.text.content_filter().
  • Wired _kid_blocked() into the say and start_story dashboard handlers, returning a clear refusal message when blocked (and blocking start_story before flipping into story_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 thread bridge/dashboard.py
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
@BrettKinny
BrettKinny merged commit 5aae9ad into main Jun 5, 2026
10 checks passed
@BrettKinny
BrettKinny deleted the audit/bridge-say-kid-filter branch June 5, 2026 10:14
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants