Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,23 @@ TZ=Australia/Brisbane
# Set to "false" for general-purpose assistant mode.
DOTTY_KID_MODE=true

# --- Admin API auth (X-Admin-Token) ---

# Shared secret protecting the /xiaozhi/admin/* routes (inject-text, say,
# set-state, play-asset, ...). `make setup` generates one into .env
# automatically if missing.
#
# Semantics: unset/empty = permissive mode — xiaozhi-server accepts admin
# calls with no auth (the pre-#149 behaviour). Set = xiaozhi-server
# requires a matching X-Admin-Token header on every admin call.
#
# Every caller reads this SAME variable: the bridge dashboard,
# dotty-behaviour's consumers, and the dotty-pi voice tools. When you set
# it, set the same value in each service's deploy-dir .env (bridge/,
# dotty-behaviour/, dotty-pi/) in the same restart window, or their admin
# calls will 401.
# DOTTY_ADMIN_TOKEN=

# --- bridge.py (admin dashboard) ---

# FastAPI listen port for the dashboard service (/ui, /admin/*, /health,
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]

### Added
- **Kid Mode: blocked-words content filter on the live voice path** (#157, closes the gap tracked in #138) — the pure matcher (three severity tiers + the kid-safe replacement) moved from `bridge/text.py` into the shared `custom-providers/textUtils.py`, the single source of truth both containers import; the bridge keeps its metrics/safety-ring/logging wrapper on top with unchanged behaviour. Both live LLM providers (`PiVoiceLLM`, `OpenAICompat`) now wrap their TTS-bound streams in the new sentence-buffered `filter_tts_stream()` — no text reaches TTS before its sentence is checked, a hit replaces the rest of the turn with the cheerful redirect (without introducing a second emoji mid-reply), and `kid_mode` off is a transparent passthrough. Honest caveat (see `docs/faq.md`): a word-level blocklist is a weak, bypassable backstop — prompt steering remains the primary defence. **Bench: needs on-device red-team verification before release sign-off.**
- **Admin-API auth: `X-Admin-Token` across the whole stack** (#149, #150, #151, #152) — xiaozhi-server's `/xiaozhi/admin/*` routes now accept an `X-Admin-Token` shared secret (`DOTTY_ADMIN_TOKEN`, timing-safe compare, permissive when unset), and all three callers — the bridge dashboard, dotty-behaviour's `XiaozhiAdminClient`, and the dotty-pi voice tools' `adminFetch` — send it. The secret is now **plumbed end-to-end**: `make setup` generates one into `.env`, the compose template / all-in-one pass it to xiaozhi-server, the three service compose files document where their copy lives, and `.env.example` + `SETUP.md` §10 describe the enable-everywhere-or-nowhere semantics. Previously the code shipped with no config path, so every deploy silently stayed permissive.
- **PersonResolver — one answer to "who is this?"** (`dotty-behaviour/household/resolver.py`) — identity resolution was smeared across consumers, and the 2026-06-06 audit found four separate identity bugs because of it. All resolution now funnels through one module with `Person.id` as the canonical key space. Fixed by the consolidation: **room_view roster recognition silently failing whenever `id != display_name`** (the VLM echoes display names; validation compared ids — confirmed 3/3, both the core and greeter paths), **multi-word display names never matching** (the NAME parser was single-token, so "Mary Anne" was a 100% silent miss — confirmed 3/3), **the greeter's calendar lookup dropping a person's own events on a case mismatch** (`[Hudson]` ≠ `hudson` — confirmed 2/3), and **bracketless `calendar_prefix:` YAML never matching**. `summarize_for_prompt` now matches person tags case-insensitively and accepts the resolver's tag set; the room_view test fakes were also fixed to carry real ids (the old fakes re-derived ids from display names, which is exactly what masked the bug).
- **Bridge systemd unit loads API keys from `${BRIDGE_DIR}/.env`** (#15) — `zeroclaw-bridge.service.template` and `scripts/install-bridge.sh` now emit `EnvironmentFile=-${BRIDGE_DIR}/.env`. `install-bridge.sh` creates a mode-0600 stub `.env` containing `OPENROUTER_API_KEY=` (and commented `VISION_API_KEY` / `VLM_API_KEY` placeholders) when one isn't already present, so the missing-vision-key failure surfaces as the bridge's existing ERROR ("camera offline") instead of a silent confabulation. Existing `.env` files are preserved.

Expand Down
16 changes: 16 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,22 @@ setup: _preflight-compose ## Interactive first-run wizard (re-runnable; remember
} > $(WIZARD_ENV); \
echo " $(WIZARD_ENV) — done"; \
echo ""; \
echo -e "$(BOLD)Ensuring .env + admin-API token...$(RESET)"; \
if [ ! -f .env ]; then \
cp .env.example .env; \
echo " .env created from .env.example"; \
fi; \
if grep -q '^DOTTY_ADMIN_TOKEN=' .env; then \
echo -e " $(GREEN)DOTTY_ADMIN_TOKEN already present in .env — keeping it.$(RESET)"; \
else \
ADMIN_TOKEN=$$(openssl rand -hex 32 2>/dev/null || head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n'); \
printf '\nDOTTY_ADMIN_TOKEN=%s\n' "$$ADMIN_TOKEN" >> .env; \
echo " generated DOTTY_ADMIN_TOKEN → .env (authenticates /xiaozhi/admin/*)"; \
echo -e " $(YELLOW)NOTE:$(RESET) set the SAME value in the bridge / dotty-behaviour /"; \
echo " dotty-pi deploy-dir .env files, or their admin calls will 401."; \
echo " See .env.example ('Admin API auth') for details."; \
fi; \
echo ""; \
echo -e "$(BOLD)Rendering templates...$(RESET)"; \
mkdir -p data; \
if [ "$$HAS_CUDA" = "1" ]; then \
Expand Down
25 changes: 25 additions & 0 deletions SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,3 +243,28 @@ It just isn't what M5Stack ships today.
- **Tail perception/behaviour**: `ssh <XIAOZHI_USER>@<XIAOZHI_HOST> 'docker logs -f dotty-behaviour'`
- **Admin dashboard**: open `http://<XIAOZHI_HOST>:8081/ui` in a browser.
- **Dashboard health**: `curl http://<XIAOZHI_HOST>:8081/health`

---

## 10. Lock down the admin API (recommended)

The `/xiaozhi/admin/*` routes (inject-text, say, set-state, play-asset, …) let
anyone who can reach port 8003 make the robot speak, move, and take photos.
`make setup` generates a shared secret, `DOTTY_ADMIN_TOKEN`, into the repo-root
`.env` — once set, xiaozhi-server rejects admin calls that don't carry it as an
`X-Admin-Token` header.

All callers read the **same variable**: copy the generated value into the
deploy-dir `.env` of each sibling service —

```bash
# on the Docker host, same value in each:
/mnt/user/appdata/dotty-bridge-src/.env # bridge dashboard
/mnt/user/appdata/dotty-behaviour-src/.env # behaviour consumers
<dotty-pi deploy dir>/.env # voice tools (adminFetch)
```

— then restart the four containers in the same window. Leaving the variable
unset everywhere keeps the legacy permissive mode (admin routes open on the
LAN); setting it on only some services 401s the ones missing it. See the
"Admin API auth" section of `.env.example`.
4 changes: 3 additions & 1 deletion bridge/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ services:
# /mnt/user/appdata/dotty-bridge-src/.env). Stays out of the deploy
# tar (only tracked files ship) and survives recreates. Use for
# OPENROUTER_API_KEY / VISION_API_KEY / VLM_API_KEY /
# AUDIO_CAPTION_API_KEY / CALENDAR_ID / etc.
# AUDIO_CAPTION_API_KEY / CALENDAR_ID / etc — and DOTTY_ADMIN_TOKEN
# (the shared X-Admin-Token secret for /xiaozhi/admin/* calls; must
# match xiaozhi-server's value or admin actions 401).
env_file:
- path: .env
required: false
Expand Down
89 changes: 38 additions & 51 deletions bridge/text.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,12 @@
if _TEXTUTILS_DIR not in sys.path:
sys.path.insert(0, _TEXTUTILS_DIR)

from textUtils import ALLOWED_EMOJIS, FALLBACK_EMOJI # noqa: E402
from textUtils import ( # noqa: E402
ALLOWED_EMOJIS,
CONTENT_FILTER_REPLACEMENT,
FALLBACK_EMOJI,
content_filter_match,
)

try:
from bridge.metrics import dotty_content_filter_hits_total
Expand Down Expand Up @@ -90,39 +95,21 @@ def truncate_sentences(text: str, max_sentences: int = MAX_SENTENCES) -> str:
return text


# Content-filter severity tiers — all tiers return the same kid-safe
# replacement so no information is leaked about WHY the filter fired. Tier
# affects logging level and the Prometheus counter label, enabling
# different alert thresholds:
# Content-filter severity tiers — the regexes and the kid-safe replacement
# live in the shared textUtils module (#157: single source of truth, also
# consumed by the live voice providers). This wrapper layers on the
# bridge-only side effects: logging level, the Prometheus counter label,
# and the /ui/safety/recent ring. All tiers return the same replacement so
# no information is leaked about WHY the filter fired:
#
# redirect — common profanity / slurs → log.warning
# log — explicit sexual / graphic violence → log.warning
# alert — hard drugs → log.error (alert on this label)
_CF_TIER_REDIRECT_RE = re.compile(
r"\b(fuck\w*|shit\w*|bitch\w*|bastard|cunt|nigger|nigga|faggot|retard(?:ed)?)\b",
re.IGNORECASE,
)
_CF_TIER_LOG_RE = re.compile(
r"\b(penis|vagina|orgasm|porn\w*|hentai|decapitat\w*|dismember\w*|mutilat\w*)\b",
re.IGNORECASE,
)
_CF_TIER_ALERT_RE = re.compile(
r"\b(cocaine|heroin|methamphetamine|fentanyl|ecstasy)\b",
re.IGNORECASE,
)

CONTENT_FILTER_REPLACEMENT = (
f"{FALLBACK_EMOJI} Let's talk about something fun instead! "
"What's your favorite animal?"
)

# Ordered highest-severity first so the most serious match wins when
# multiple tiers could fire on the same text.
_CF_TIERS: list[tuple[re.Pattern, str, int]] = [
(_CF_TIER_ALERT_RE, "alert", logging.ERROR),
(_CF_TIER_LOG_RE, "log", logging.WARNING),
(_CF_TIER_REDIRECT_RE, "redirect", logging.WARNING),
]
_CF_TIER_LEVELS = {
"alert": logging.ERROR,
"log": logging.WARNING,
"redirect": logging.WARNING,
}

# #72 — in-memory ring of recent content-filter hits, surfaced at
# /ui/safety/recent. In-memory ONLY: the ring is lost on restart and is
Expand All @@ -148,24 +135,24 @@ def content_filter(text: str) -> str | None:
letting operators alert on ``tier="alert"`` without noising up
lower-tier counts.
"""
for pattern, tier, level in _CF_TIERS:
match = pattern.search(text)
if match:
log.log(
level,
"content-filter-hit tier=%s pattern=%r pos=%d len=%d",
tier, match.group(), match.start(), len(text),
)
_cf_recent.append({
"ts": time.time(),
"tier": tier,
"rule": match.group(),
"prefix": text[:8],
})
if dotty_content_filter_hits_total is not None:
try:
dotty_content_filter_hits_total.labels(tier=tier).inc()
except Exception:
pass
return CONTENT_FILTER_REPLACEMENT
return None
hit = content_filter_match(text)
if hit is None:
return None
tier, match = hit
log.log(
_CF_TIER_LEVELS.get(tier, logging.WARNING),
"content-filter-hit tier=%s pattern=%r pos=%d len=%d",
tier, match.group(), match.start(), len(text),
)
_cf_recent.append({
"ts": time.time(),
"tier": tier,
"rule": match.group(),
"prefix": text[:8],
})
if dotty_content_filter_hits_total is not None:
try:
dotty_content_filter_hits_total.labels(tier=tier).inc()
except Exception:
pass
return CONTENT_FILTER_REPLACEMENT
6 changes: 6 additions & 0 deletions compose.all-in-one.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@ services:
# the firmware pips desync from the dashboard on every reconnect.
- DOTTY_KID_MODE_STATE=/var/lib/dotty-bridge/state/kid-mode
- DOTTY_SMART_MODE_STATE=/var/lib/dotty-bridge/state/smart-mode
# Shared admin-API secret (X-Admin-Token). Read from ./.env — `make
# setup` generates it. Unset/empty = permissive (admin routes open);
# set = this server enforces it AND every caller (bridge,
# dotty-behaviour, dotty-pi) must carry the same value in its own
# .env. See .env.example.
- DOTTY_ADMIN_TOKEN=${DOTTY_ADMIN_TOKEN:-}
ports:
# 8000: WebSocket — StackChan connects here
- "8000:8000"
Expand Down
20 changes: 19 additions & 1 deletion custom-providers/openai_compat/openai_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
FALLBACK_EMOJI,
_SENTENCE_BOUNDARY,
build_turn_suffix,
filter_tts_stream,
)

TAG = __name__
Expand Down Expand Up @@ -259,10 +260,27 @@ def _response_stream(self, messages):
# public interface (called by xiaozhi-server)
# ------------------------------------------------------------------

def _on_filter_hit(self, tier, match):
# Local logging only — the Prometheus counter / safety ring live in
# the bridge container, which this provider can't reach.
logger.bind(tag=TAG).warning(
f"OpenAICompat content-filter hit tier={tier} "
f"pattern={match.group()!r} — turn replaced"
)

def response(self, session_id, dialogue, **kwargs):
"""Generate a response. Yields string chunks.

Uses streaming by default. The interface matches LLMProviderBase.
#157: in kid mode the stream is wrapped in the shared blocked-content
filter (sentence-buffered, so nothing reaches TTS before its sentence
is checked; a hit replaces the rest of the turn). The emoji-prefix
enforcement inside _response_stream runs first, so the filter sees —
and preserves — the leading-emoji contract.
"""
messages = self._build_messages(dialogue)
yield from self._response_stream(messages)
yield from filter_tts_stream(
self._response_stream(messages),
KID_MODE,
on_hit=self._on_filter_hit,
)
24 changes: 22 additions & 2 deletions custom-providers/pi_voice/pi_voice.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,10 @@ def setup_logging(): # type: ignore[no-redef]
# makes it unimportable as a package), so we fall back to loading it
# by absolute path. Both code paths end up with the same module.
try:
from core.utils.textUtils import build_turn_suffix # type: ignore
from core.utils.textUtils import ( # type: ignore
build_turn_suffix,
filter_tts_stream,
)
except ImportError: # pragma: no cover — dev workstation fallback
import importlib.util as _ilu
from pathlib import Path as _Path
Expand All @@ -71,6 +74,7 @@ def setup_logging(): # type: ignore[no-redef]
_tu = _ilu.module_from_spec(_spec)
_spec.loader.exec_module(_tu)
build_turn_suffix = _tu.build_turn_suffix # type: ignore[attr-defined]
filter_tts_stream = _tu.filter_tts_stream # type: ignore[attr-defined]


TAG = __name__
Expand Down Expand Up @@ -141,14 +145,30 @@ def response(self, session_id, dialogue, **kwargs) -> Iterator[str]:
self._first_turn = False

try:
for chunk in self._client.iter_turn_text(prompt):
# #157: kid-mode blocked-content filter on TTS-bound output.
# Sentence-buffered — nothing reaches TTS before its sentence is
# checked; a hit replaces the rest of the turn. kid_mode off is a
# transparent passthrough.
for chunk in filter_tts_stream(
self._client.iter_turn_text(prompt),
self._kid_mode,
on_hit=self._on_filter_hit,
):
yield chunk
except PiClientError as exc:
logger.error("PiVoiceLLM turn failed: %s", exc)
for line in self._client.recent_stderr()[-5:]:
logger.error(" pi.stderr: %s", line)
yield "(brain offline — try again in a moment)"

def _on_filter_hit(self, tier: str, match) -> None:
# Local logging only — the Prometheus counter / safety ring live in
# the bridge container, which this provider can't reach.
logger.warning(
"PiVoiceLLM content-filter hit tier=%s pattern=%r — turn replaced",
tier, match.group(),
)

def close(self) -> None:
"""xiaozhi may call this on shutdown — make sure pi cleans up."""
self._client.close()
Loading
Loading