This commit is contained in:
Javis committed 2026-10-04 15:31:44 +02:00
1 parent 0246cbb6ba
commit 0ca4b4bf7a
89 files changed
+7042 -2480

No files matched your search

+386
View File
@@ -0,0 +1,386 @@
# Javis Slave & Slave-MCP — Code Audit
**Scope:** [javis-slave](../javis-slave) (satellite app: touch GUI + headless/Raspberry Pi
mode, ~3.6 k lines) and [javis-slave-mcp](../javis-slave-mcp) (MCP server exposing the
satellites' controls to the master, ~1 k lines), including `pyproject.toml`,
`setup-pi.sh`, the systemd unit and the READMEs.
**Method** — same as [AUDIT.md](AUDIT.md): every file read line by line, `ruff
--select F,E9,B` baseline (8 findings), and each claim reproduced where possible:
* the MCP tool failure was reproduced by calling the tool through FastMCP
(`mcp` 1.29.0 in `javis-slave-mcp/.venv`);
* the unauthenticated control API was reproduced with FastAPI's `TestClient`;
* the Windows volume bug was reproduced in a scratch venv with the current `pycaw`
(20260927, comtypes 1.4.17).
Line numbers refer to the audited revision `0246cbb6`.
---
## 1. Architecture
```mermaid
flowchart LR
subgraph Satellite["javis-slave (tablet / Raspberry Pi)"]
MIC[MicStream + resampler] --> WW[openWakeWord]
MIC --> REC[record_utterance + VAD]
ENG[AudioEngine] --> SPK[play_pcm]
CTRL["Control API :8766<br/>/mcp/v1/*"] --> SYS[volume / brightness / power]
UI[Touch UI or headless log]
end
WW --> ENG
REC --> ENG
ENG -- "POST /api/v1/turn (Bearer key)" --> MASTER[Javis master :8765]
ENG -- "GET /api/v1/config (60 s poll)" --> MASTER
MCP[javis-slave-mcp<br/>FastMCP stdio] -- HTTP --> CTRL
MASTER -- spawns --> MCP
CTRL -- "pass-through /api/v1/slaves" --> MASTER
```
* The satellite owns the wake word and end-pointing; the master does STT → LLM → TTS
and returns WAV.
* The control API is a FastAPI app served by uvicorn inside the same event loop
(qasync in GUI mode, `asyncio.run` headless).
* `javis-slave-mcp` is spawned by the master's MCP host and calls each configured
satellite's control API.
---
## 2. Findings summary
| ID | Sev. | Component | Title | Status |
|---|---|---|---|---|
| [MCP-S1](#mcp-s1) | Critical | slave-mcp | Every device tool fails: `asyncio.run()` inside the server's event loop | Fixed |
| [SLV-01](#slv-01) | Critical | slave / security | Control API (power, mic, volume, master pass-through) has no authentication | Fixed |
| [SLV-02](#slv-02) | High | slave / Windows | Volume control broken with current `pycaw` | Fixed |
| [SLV-03](#slv-03) | Medium | slave | Turn upload / pairing retried after a read timeout → duplicate turn, burnt code | Fixed |
| [SLV-04](#slv-04) | Medium | slave | Failed engine start leaks the open microphone (ALSA: busy forever) | Fixed |
| [SLV-05](#slv-05) | Medium | slave | Playback thread keeps writing while the stream is closed | Fixed |
| [SLV-06](#slv-06) | Medium | slave / security | Master-supplied wake-word name used as a file path | Fixed |
| [SLV-07](#slv-07) | Medium | slave | Corrupt config silently replaced → device_id and pairing lost | Fixed |
| [SLV-08](#slv-08) | Medium | slave / security | `slave_config.json` (API key) world-readable on Linux | Fixed |
| [MCP-S2](#mcp-s2) | Medium | slave-mcp | `slave_get_ip_address` looks up the alias instead of the device UUID | Fixed |
| [MCP-S3](#mcp-s3) | Medium | slave-mcp | Power tools not marked destructive | Fixed (annotations) |
| [SLV-09](#slv-09) | Low | slave / Windows | Power commands report success regardless of exit code | Fixed |
| [SLV-10](#slv-10) | Low | slave | Retry counter double-counted; misleading "re-pair manually" error | Fixed |
| [SLV-11](#slv-11) | Low | slave | 30 ms chunks zero-padded to Silero's 512-sample window | Fixed |
| [SLV-12](#slv-12) | Low | slave | Mic callback can raise during shutdown | Fixed |
| [SLV-13](#slv-13) | Low | slave | Fire-and-forget tasks without strong references | Fixed |
| [SLV-14](#slv-14) | Low | slave / GUI | Transcript bubbles render LLM text as rich text | Fixed |
| [MCP-S4](#mcp-s4) | Low | slave-mcp | `JAVIS_SLAVE_DEVICES` parsing fragile | Fixed |
| [MCP-S5](#mcp-s5) | Low | slave-mcp | 25 copy-pasted tool bodies, client per call, README drift | Fixed |
| [LINT-S](#lint-s) | Low | both | Ruff F/B findings | Fixed |
| [SLV-15](#slv-15) | Info | slave | `windows_control.py` is an unused copy of `system_control.py` | Open (delete?) |
| [SLV-16](#slv-16) | Info | slave | uvicorn's signal capture overrides the headless SIGTERM handler | Fixed (pin) |
| [SLV-17](#slv-17) | Info | slave / security | Plain HTTP; the satellite does not authenticate the master | Open (recommendation) |
| [SLV-18](#slv-18) | Info | Pi deployment | systemd unit has no sandboxing | Open (recommendation) |
---
## 3. Detailed findings
### javis-slave-mcp
#### MCP-S1
**Every device tool fails: `asyncio.run()` inside the server's event loop** — *Critical*
All 24 device tools are plain `def` functions that call
`asyncio.run(_slaves[id].get_status())` (server.py L319–L816). FastMCP 1.x calls a
synchronous tool **directly on its running event loop**
(`FuncMetadata.call_fn_with_arg_validation`: `return fn(**args)`), and `asyncio.run`
refuses to start a second loop there. Reproduced:
```
slave_get_volume → ERROR: asyncio.run() cannot be called from a running event loop
```
So "mach die Küche leiser" could never work; the master only ever got `ERROR:` back.
**Fix:** all tools are `async def` and await the client directly; one `_run()` helper
raises failures as `ToolError`, which FastMCP returns as a result with
`isError: true` (the master's MCP host turns that into its usual `ERROR:` text).
#### MCP-S2
**`slave_get_ip_address` looks up the alias instead of the device UUID** — *Medium*
`get_slave_ip(device_id)` passes the MCP alias (`"localhost"`, `"kitchen"`) to
`/mcp/v1/master/slaves/{id}/ip`, but the master keys satellites by their UUID, so the
lookup always returned 404. **Fix:** the tool first reads the satellite's real
`device_id` from `/mcp/v1/status`.
#### MCP-S3
**Power tools not marked destructive** — *Medium*
`slave_shutdown`, `slave_restart_system`, `slave_sleep` are indistinguishable from
read-only tools, and the shipped `mcp_servers.json` sets `"approval": "allow"` for this
server, so a mis-heard request can power off a satellite without confirmation.
**Fix:** MCP tool annotations (`readOnlyHint`, `destructiveHint`, `idempotentHint`)
on every tool. The master's MCP host now asks for approval for `destructiveHint`
tools even on `allow` servers (`mcp.confirm_destructive`, default `true`; added in the
review pass after [AUDIT_MCP.md](AUDIT_MCP.md)).
#### MCP-S4
**`JAVIS_SLAVE_DEVICES` parsing fragile** — *Low*
Keys were not stripped (`"a=…, kitchen=…"` registered `" kitchen"`), and one malformed
entry aborted parsing of all following ones. **Fix:** per-entry parsing with stripped
keys and a warning per bad entry; optional token per entry.
#### MCP-S5
**Duplication and drift** — *Low*
24 copies of the same try/`asyncio.run`/except block, a new `httpx.AsyncClient` per
call, unused imports, and README entries that don't match the code
(`slave_sleep` has no `delay`; brightness steps default to 5, not 10).
**Fix:** one shared client per satellite (`trust_env=False`, so the token is never sent
through an environment proxy), one `_run()` helper, README corrected.
### javis-slave
#### SLV-01
**Control API has no authentication** — *Critical*
`build_http_app` (http_server.py) registers `/mcp/v1/power/{sleep,shutdown,restart}`,
mic mute, volume, brightness, engine restart and the master pass-through
`/mcp/v1/master/slaves` without any auth, and `api_host` defaults to `0.0.0.0`.
Reproduced: `POST /mcp/v1/power/cancel` from an unauthenticated client → **200**.
* Anyone on the LAN can power off / reboot the satellite, silence its microphone or
read the master's full device list (names, rooms, IPs) through the pass-through,
which uses the satellite's own master credentials.
* Even on loopback, any web page open on the device can trigger them: a body-less
`POST` is a CORS "simple request", so the browser sends it without a preflight
(classic CSRF).
**Fix:**
* each satellite gets a random `control_token` (generated on first start, stored in
its config; `--control-token` sets a household-wide one, `--show-control-token`
prints it, the settings dialog shows it);
* every endpoint except `/mcp/v1/health` requires `Authorization: Bearer <token>`
(constant-time compare);
* zero-config compatibility: callers on **loopback** may omit the token but must send
`X-Javis-Control: 1`. A browser cannot add that header cross-origin without a
preflight, which this server never grants, so CSRF stays blocked;
* `javis-slave-mcp` sends the header always and the token from `JAVIS_SLAVE_TOKEN`
or per device.
#### SLV-02
**Volume control broken with current `pycaw`** — *High*
`VolumeController` calls `AudioUtilities.GetSpeakers().Activate(IAudioEndpointVolume._iid_, 0, None)`
and uses the result directly. Since pycaw 2024 `GetSpeakers()` returns an
`AudioDevice` wrapper **without** `Activate` (it exposes `.EndpointVolume`); on older
pycaw the returned `IUnknown` pointer also needed a `cast` to `IAudioEndpointVolume`.
Reproduced: `AttributeError: 'AudioDevice' object has no attribute 'Activate'` → the
controller disables itself and every volume endpoint fails on Windows.
**Fix:** use `EndpointVolume` when present, else the documented
`cast(Activate(..., CLSCTX_ALL, None), POINTER(IAudioEndpointVolume))`. The endpoint is
resolved per call, so it follows default-device changes and no COM pointer is shared
between `asyncio.to_thread` workers (calling from workers was verified to work).
#### SLV-03
**Turn upload / pairing retried after a read timeout** — *Medium*
`_retryable_request` retries on any `httpx.HTTPError`, including `ReadTimeout` /
`RemoteProtocolError`, i.e. after the master already received the request.
`send_turn` (`retries=1`) then re-runs the whole STT → LLM → tools turn ("add milk to
the list" twice), and `pair` re-submits a single-use code that was already consumed
(the user sees "code invalid" although pairing succeeded on the master).
**Fix:** non-idempotent calls only retry when the connection could not be established
(`ConnectError`, `ConnectTimeout`).
#### SLV-04
**Failed engine start leaks the open microphone** — *Medium*
`AudioEngine.start()` opens the mic, then builds the VAD and loads/downloads the wake
model. If the latter fails (custom model not on the master, ONNX error), the started
`InputStream` is never closed. Both the GUI and headless runners retry every 10 s with
a new engine: each retry opens another stream; on a Raspberry Pi with an ALSA `hw:`
device the second open fails with "Device or resource busy", so the satellite never
recovers without a restart. **Fix:** `start()` stops everything it opened on failure.
#### SLV-05
**Playback thread keeps writing while the stream is closed** — *Medium*
Same defect as master AGT-06: `play_pcm` awaits `asyncio.to_thread(_writer)`. Restart,
re-pair or shutdown during a reply cancels the engine task; `to_thread` returns at
once while the thread keeps calling `stream.write()`, and the `finally` block calls
`stop()/close()` concurrently. **Fix:** cancellation-safe block writer that stops and
joins the writer thread before the stream is closed.
#### SLV-06
**Master-supplied wake-word name used as a file path** — *Medium*
`_load_wake` downloads to `config_dir()/models/f"{name}.onnx"` with `name` taken from
the master's `/config` response. Over plain HTTP (SLV-17), a spoofed or compromised
master could send `../../.config/autostart/x` and write files outside the models
folder. **Fix:** names containing path separators or `..` are rejected.
#### SLV-07
**Corrupt config silently replaced** — *Medium*
`load_config()` swallows any parse error and returns fresh defaults, including a
**new random `device_id`**; both entry points immediately `save_config()` it. A
truncated `slave_config.json` (SD card + power loss — `tmp.replace` without `fsync`
is not durable) therefore wipes the pairing and the satellite reappears as a new device.
**Fix:** unreadable files are preserved as `slave_config.json.corrupt` with an error
log; saves `fsync` before the atomic replace; values from JSON are type-checked.
#### SLV-08
**`slave_config.json` (API key) world-readable on Linux** — *Medium*
The file holds the master API key (and now the control token) but is created with the
default umask (0644): every local user on the Pi can read it. **Fix:** `chmod 600` on
POSIX after every write.
#### SLV-09
**Windows power commands report success regardless of exit code** — *Low*
`subprocess.run(["shutdown", ...], check=False)` → always `True`, e.g.
`/mcp/v1/power/cancel` said "cancelled" when nothing was pending (exit 1116), and a
console window flashed in GUI mode. **Fix:** shared runner with timeout,
`CREATE_NO_WINDOW` and return-code check (exit 1116 "no shutdown in progress" counts as
success for *cancel*). `sleep` is started without waiting: `SetSuspendState` only
returns after resume, which held the HTTP reply until the machine woke up.
#### SLV-10
**Retry counter double-counted; misleading error** — *Low*
`_retryable_request` already calls `mark_disconnected()` per attempt; `_handle_turn`
and `_config_poll_loop` called it again, so the counter grew twice as fast. After 10
the UI said "Maximale Wiederverbindungsversuche erreicht. Bitte manuell neu koppeln."
although polling continues and recovers automatically. **Fix:** single bookkeeping
point; the message now says reconnection keeps being attempted.
#### SLV-11
**30 ms chunks zero-padded** — *Low*
Same as master AUD-02. **Fix:** 32 ms (512 samples @ 16 kHz).
#### SLV-12
**Mic callback can raise during shutdown** — *Low*
Same as master AUD-04. **Fix:** guarded.
#### SLV-13
**Fire-and-forget tasks** — *Low*
`ensure_future()` results discarded for chimes, wake-model swaps, pairing, mute,
HTTP-server start, engine retry. **Fix:** small `spawn()` helper keeping references
and logging failures.
#### SLV-14
**Transcript bubbles render LLM text as rich text** — *Low*
`QLabel(f"🤖 {reply_text}")` uses `Qt::AutoText`; replies containing HTML (e.g.
echoed from web search results) are rendered, including `<img src="file:…">`.
**Fix:** `Qt.PlainText` for user/assistant/error bubbles.
#### SLV-15
**`windows_control.py` is an unused copy** — *Info*
It differs from `system_control.py` only in the logger import and is imported nowhere.
Recommend deleting it (left in place pending your confirmation).
#### SLV-16
**uvicorn overrides the headless SIGTERM handler** — *Info*
`Server.serve()` installs its own SIGINT/SIGTERM handlers after the headless runner
registered `stop.set`. uvicorn ≥ 0.29 re-raises captured signals when it exits, so
shutdown still works — older versions (the spec allowed `>=0.24`) would leave
`systemctl stop` hanging until SIGKILL. **Fix:** `uvicorn>=0.29`.
#### SLV-17
**Plain HTTP; the satellite does not authenticate the master** — *Info*
API key, transcripts and reply audio travel unencrypted, and anything answering on the
master's IP is trusted (wake-word settings, model downloads). 📌 Run the master behind
TLS (Caddy) or keep satellites on a trusted VLAN; SLV-06 limits the worst case.
#### SLV-18
**systemd unit has no sandboxing** — *Info*
📌 Consider `ProtectSystem=full`, `ProtectHome=read-only`, `PrivateTmp=yes`,
`ReadWritePaths=/opt/javis-slave/config` (sudo for `shutdown` keeps working;
`NoNewPrivileges=yes` would not). Not applied: it can't be tested here, and
`ProtectHome` breaks installs under `/home`; see §6 for the options that must stay off.
#### LINT-S
Unused imports (`SlaveConfig`, `Any`, `Context`, `Field`), missing exception chaining
(B904 ×2), f-strings without placeholders (F541 ×2). **Fixed.**
---
## 4. Reviewed and rejected
| Claim | Why rejected |
|---|---|
| Stateful resampler drops/duplicates samples between blocks | Phase carry-over and the `_last` sample are handled correctly; output length tracks the ratio. |
| `MicStream.stop()` can block a consumer forever | `_drain_and_sentinel` evicts until the sentinel fits. |
| Mute race between API and UI | `_mute_lock` + `_unmuted` event serialise it. |
| Pairing code brute force from the satellite side | Codes are validated and rate-limited on the master (see master API audit). |
---
## 5. Verification
| Check | Result |
|---|---|
| `ruff check javis-slave/src javis-slave-mcp/src --select F,E9,B` | clean (was 8 findings) |
| `pytest tests` (main venv) | 59 passed, 1 skipped (`test_slave_mcp.py` needs mcp 1.x) |
| `javis-slave-mcp/.venv` → `pytest tests/test_slave_mcp.py` | 5 passed |
| Offscreen GUI smoke (`SlaveWindow`, bubbles, settings dialog) | plain-text bubbles, token field masked |
New regression tests:
* [tests/test_slave_audit.py](../tests/test_slave_audit.py):
* Config: a corrupt file is kept as `.corrupt`, loading is type-checked, and the token round-trips.
* Control API:
* LAN callers need the Bearer token.
* Loopback callers need `X-Javis-Control`.
* Plain loopback requests (CSRF) and empty tokens are refused.
* `/docs` is gone.
* Master client: `send_turn` does not retry after `ReadTimeout`, but retries a `ConnectError` once.
* Audio:
* A failed engine start releases the mic.
* Wake-model names with path parts are rejected before any download.
* Cancelled playback stops writing before the stream can be closed.
* System control: the pycaw `EndpointVolume` path and Windows exit-code handling.
* [tests/test_slave_mcp.py](../tests/test_slave_mcp.py):
* Env parsing skips bad entries and never puts the token in `repr`.
* A tool called through `FastMCP.call_tool` on a running loop resolves the real UUID and sends both auth headers.
* 401 responses and unknown devices become tool errors.
* Power tools carry `destructiveHint`.
No test calls a real power, volume or brightness endpoint. The controllers are faked, or the
auth middleware rejects the request before the handler runs.
---
## 6. Improvements and best practices
Researched after the fix pass; ✅ = applied, 📌 = recommendation.
| Source | Guidance | Status |
|---|---|---|
| [MCP spec — Tools](https://modelcontextprotocol.io/specification/2025-06-18/server/tools) | Tool execution failures belong in the result with `isError: true`. Protocol errors are for unknown tools and bad arguments. | ✅ `ToolError` from `_run()` |
| MCP spec — Tools | Annotations describe behaviour, but clients **MUST** treat them as untrusted unless the server is trusted. A human **SHOULD** be able to deny invocations. | ✅ Annotations on all 23 tools. ✅ The master's MCP host asks before `destructiveHint` tools, even on `approval: allow` servers. |
| MCP spec — Tools, security | Servers **MUST** validate inputs and implement access control, and **SHOULD** rate-limit. | ✅ FastAPI `Query(ge/le)` bounds + auth. 📌 No rate limit on power endpoints yet. |
| [OWASP CSRF cheat sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html) | Custom request headers are a valid CSRF defence for APIs because they force a CORS preflight. Never change state via `GET`. Compare secrets in constant time. | ✅ `X-Javis-Control` on loopback, no CORS middleware, all state changes use `POST`, `hmac.compare_digest`. 📌 Optionally also reject `Sec-Fetch-Site: cross-site`. |
| [uvicorn 0.29.0 release](https://github.com/encode/uvicorn/releases/tag/0.29.0) | "Cooperative signal handling" (#1600): captured signals are re-raised after shutdown. | ✅ `uvicorn>=0.29` |
| [pycaw README](https://github.com/AndreMiras/pycaw) | Current API: `AudioUtilities.GetSpeakers().EndpointVolume`. | ✅ Used, with an `Activate`+`cast` fallback for old pycaw |
| [systemd.exec(5)](https://man7.org/linux/man-pages/man5/systemd.exec.5.html) | Enable `ProtectSystem=` for all long-running services and `ProtectHome=` for network-facing ones. `NoNewPrivileges=` blocks setuid, `ProtectKernelTunables=` makes `/sys` read-only, and `PrivateDevices=` removes physical devices. | 📌 Safe set for this unit: `ProtectSystem=full`, `PrivateTmp=yes`, `ProtectHome=read-only` (only for `/opt` installs). These must stay **off**: `NoNewPrivileges` (sudo power control), `ProtectKernelTunables` (backlight), `PrivateDevices` (audio). |
| systemd.exec(5), *Environment=* | Environment variables are not suitable for secrets: they are visible via D-Bus and inherited by child processes. | 📌 For remote satellites, prefer `"token"` in `~/.javis/slave_mcp_config.json` (keep it 0600) over `JAVIS_SLAVE_TOKEN` in `mcp_servers.json`. |
| Python `hmac.compare_digest` / `secrets.token_urlsafe` | Use constant-time comparison and CSPRNG tokens. | ✅ 24-byte URL-safe token, constant-time check |
Observations (no action):
* `mcp` 1.29 with the current `pydantic-settings` prints an `IncompleteFieldDefinitionWarning`
(`lifespan`) when `FastMCP()` is constructed. This is an upstream SDK issue and harmless.
* `mcp` 2.x removed `mcp.server.fastmcp`, so `javis-slave-mcp` now pins `mcp>=1.10,<2.0`
(tool annotations need ≥ 1.10). The master's MCP host already supports both.
* Still open:
* SLV-15: delete `windows_control.py`?
* SLV-17: TLS.
* SLV-18: sandboxing.
* Rate limiting.