4 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 5.5 fc7f0cc1fd chore: file T-1 — per-device token for the gateway
Test, Build and Push / test-gateway (push) Successful in 14s
Test, Build and Push / release (push) Skipped
Test, Build and Push / build-gateway (push) Skipped
The first ticket on this board: /ws/voice and /devices accept any
client on the LAN, which reaches Tatlock and its Home Assistant
controls. Found in the workspace security sweep of 2026-09-23.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 13:45:19 +02:00
jpmschweitzerandClaude f36f0fe431 fix(permissions): narrow rm -rf deny globs to their exact forms
Test, Build and Push / test-gateway (push) Successful in 13s
Test, Build and Push / release (push) Skipped
Test, Build and Push / build-gateway (push) Skipped
The trailing wildcard on the three rm -rf deny entries spanned path
separators, so Bash(rm -rf /*) matched every absolute path on the
machine rather than the filesystem root, and the ~ and $HOME entries
had the same shape. Narrowed to the exact literal forms.

These rules match literal command text, so they still stop a typo on
rm -rf /, rm -rf ~ or rm -rf $HOME exactly, but they no longer stop a
recursive delete aimed at any other path. That reduced cover is
deliberate, not an oversight.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-25 20:33:17 +02:00
jpmschweitzerandClaude 8bdf950fcf build(lint): select ruff's rules explicitly instead of inheriting them
Test, Build and Push / test-gateway (push) Successful in 11s
Test, Build and Push / release (push) Skipped
Test, Build and Push / build-gateway (push) Skipped
The gate had no `select`, so it linted with whatever the installed ruff
version defaults to. `dev` pins only `ruff>=0.6` and CI installs that
extra fresh on every run, which made the rule set a function of when pip
last resolved rather than of this code. Two developers on one commit
could get different answers, and so could CI and a laptop.

This surfaced when T-47's converged setup reinstalled ruff and pulled
0.16.3: `make lint` failed on UP017 and BLE001 in main.py, a file the
commit before it had not touched. Under the previous install the same
code passed. Nothing about the code changed — only the linter's idea of
what to look at, which had grown to 413 rules with nobody choosing them.

Naming the families fixes that; pinning the version would only have
frozen the symptom and moved the surprise to whoever unpinned it. 217
rules now, selected on purpose, and a future ruff release becomes a
decision instead of a broken push.

ASYNC is included deliberately — this is a websocket gateway, and it is
the family whose findings would be real bugs rather than style. BLE is
deliberately excluded: main.py catches bare Exception when a device
disappears mid-send, which is correct there, and selecting BLE would
mean a noqa on every such site to say so.

UP017 is fixed rather than suppressed (datetime.timezone.utc -> UTC,
identical semantics, and requires-python is already >=3.11); isort then
reordered the import, which is the whole of the main.py diff.

Verified the selection is load-bearing rather than decorative: a probe
file with a mutable default argument fails the explicit set (B006, exit
1) and passes ruff's minimal default set (exit 0), so the rules named
here are doing work the fallback would not. Probe deleted; lint,
typecheck and the 9-test suite all green after.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 12:36:37 +02:00
jpmschweitzer 78d9b276ee build(make): prove make setup actually works before exiting 0 (T-47)
pip install exiting 0 is not evidence the gateway environment is usable
(D-24) — a resolved-but-broken dependency or a stale venv from another
Python both look identical to a clean install at the point setup exits.
End the target with a cheap positive check instead: collect the test
suite (imports every src module each test pulls in) and confirm ruff
and mypy resolve inside the venv, the only place either binary exists.

Also states explicitly, in a comment, that setup covers the gateway
half only — the firmware half needs `source ~/esp-idf/export.sh` in
every shell, which a Makefile recipe cannot leave sourced in the
caller's shell, so build-firmware sources it itself instead.
2026-08-17 12:04:12 +02:00
8 changed files with 91 additions and 6 deletions
+3 -3
View File
@@ -58,9 +58,9 @@
"Bash(git reset --hard*)", "Bash(git reset --hard*)",
"Bash(git restore .*)", "Bash(git restore .*)",
"Bash(mkfs*)", "Bash(mkfs*)",
"Bash(rm -rf $HOME*)", "Bash(rm -rf $HOME)",
"Bash(rm -rf /*)", "Bash(rm -rf /)",
"Bash(rm -rf ~*)", "Bash(rm -rf ~)",
"Bash(su *)", "Bash(su *)",
"Bash(sudo *)", "Bash(sudo *)",
"Bash(toj)", "Bash(toj)",
+19
View File
@@ -0,0 +1,19 @@
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GCW5B4X073XJCWS4QTHRAVSG', 'description', NULL, 'The gateway accepts any client. `/ws/voice` and `/devices` check no credential, so anything on the LAN (or the tailnet) can open `ws://192.168.86.149:8600/ws/voice` and talk to Tatlock through the gateway — including Tatlock''s Home Assistant controls — or list the devices. Found in the workspace security sweep on 2026-09-23 by reading the gateway source: no token, auth or secret handling anywhere in `gateway/src/desklock_gateway/`.
Port 8600 has to stay LAN-open: it is the one address the firmware connects to (`GATEWAY_WS_URI` in `firmware/main/secrets.h`). So the fix is a credential, not a lockdown.
## Shape
- A per-device token, provisioned into firmware through `secrets.h` (already the secrets path, and already gitignored — check), sent on the WebSocket handshake (an `Authorization: Bearer` header, or a first frame if the ESP-IDF client makes headers awkward).
- The gateway compares it in constant time against its configured set (one token per device, so one can be revoked alone) and closes the socket on a mismatch before any audio or text is processed.
- `/devices` requires the same credential, or an admin one.
- Tokens reach the gateway container as a Portainer stack variable, never in the tracked stack file.
- The protocol change is recorded in `docs/architecture.md` (standing rule: device–gateway protocol changes update it).
## Rollout without a dark period
Gateway first in a mode that accepts both token and no-token and logs which each device used; then flash the devices; then make the token required. Verify: a connection without a token is refused, each flashed device still converses, and `/devices` without a credential is refused.
## Tests
A socket without a token is closed before the first frame is read; a wrong token likewise; a correct one converses; comparison is constant-time. Each mutation-checked.', NULL, '2026-09-23 11:45:03', '2026-09-23 11:45:03.865', '2026-09-23 11:45:03.865', NULL, '93027bc356d0bbc60ae0e38b9afdeb88', 2) ON CONFLICT(hash) DO NOTHING;
+1
View File
@@ -0,0 +1 @@
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GCW5B4X073XJCWS4QTHRAVSG', 'T-1', '2026-09-23 11:45:03.721', '2026-09-23 11:45:03.721', NULL, '4549dc91ac440f4b47a95e9072256904', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
+20
View File
@@ -0,0 +1,20 @@
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GCW5B4X073XJCWS4QTHRAVSG', 'story', NULL, 'Gateway: per-device token on /ws/voice and /devices', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-09-23 11:45:03.720', '2026-09-23 11:45:03.720', NULL, '7ef01f11d6709f6e094634f5aacc6789', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GCW5B4X073XJCWS4QTHRAVSG', 'story', NULL, 'Gateway: per-device token on /ws/voice and /devices', 'The gateway accepts any client. `/ws/voice` and `/devices` check no credential, so anything on the LAN (or the tailnet) can open `ws://192.168.86.149:8600/ws/voice` and talk to Tatlock through the gateway — including Tatlock''s Home Assistant controls — or list the devices. Found in the workspace security sweep on 2026-09-23 by reading the gateway source: no token, auth or secret handling anywhere in `gateway/src/desklock_gateway/`.
Port 8600 has to stay LAN-open: it is the one address the firmware connects to (`GATEWAY_WS_URI` in `firmware/main/secrets.h`). So the fix is a credential, not a lockdown.
## Shape
- A per-device token, provisioned into firmware through `secrets.h` (already the secrets path, and already gitignored — check), sent on the WebSocket handshake (an `Authorization: Bearer` header, or a first frame if the ESP-IDF client makes headers awkward).
- The gateway compares it in constant time against its configured set (one token per device, so one can be revoked alone) and closes the socket on a mismatch before any audio or text is processed.
- `/devices` requires the same credential, or an admin one.
- Tokens reach the gateway container as a Portainer stack variable, never in the tracked stack file.
- The protocol change is recorded in `docs/architecture.md` (standing rule: device–gateway protocol changes update it).
## Rollout without a dark period
Gateway first in a mode that accepts both token and no-token and logs which each device used; then flash the devices; then make the token required. Verify: a connection without a token is refused, each flashed device still converses, and `/devices` without a credential is refused.
## Tests
A socket without a token is closed before the first frame is read; a wrong token likewise; a correct one converses; comparison is constant-time. Each mutation-checked.', 'backlog', 'high', NULL, NULL, NULL, '2026-09-23 11:45:03.720', '2026-09-23 11:45:03.865', NULL, 'de24fb675828ba55b4fdd3defd406cdb', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
+3
View File
@@ -14,6 +14,9 @@ until the first tagged release.
removed. `make test` means the same thing from any directory. removed. `make test` means the same thing from any directory.
- Firmware targets source `~/esp-idf/export.sh` themselves, so `idf.py` resolves without - Firmware targets source `~/esp-idf/export.sh` themselves, so `idf.py` resolves without
having to remember. Override the location with `IDF_EXPORT=`. having to remember. Override the location with `IDF_EXPORT=`.
- `make setup` now proves the gateway environment actually works instead of trusting a
clean `pip install` exit code: it collects the test suite and checks `ruff`/`mypy`
resolve in the venv, and fails the target if any of that is broken (T-47).
## [0.2.2] — 2026-07-19 ## [0.2.2] — 2026-07-19
+21 -1
View File
@@ -44,8 +44,28 @@ lint: lint-gateway ## Lint every component that has a linter
# --- gateway ------------------------------------------------------------------ # --- gateway ------------------------------------------------------------------
.PHONY: setup .PHONY: setup
setup: ## Create the gateway venv and install dev deps (no ML models) setup: ## Gateway venv + dev deps + prove it works (firmware needs export.sh — see below)
cd $(GATEWAY) && $(PYTHON) -m venv .venv && .venv/bin/pip install -e ".[dev]" cd $(GATEWAY) && $(PYTHON) -m venv .venv && .venv/bin/pip install -e ".[dev]"
@# This covers the gateway half only, deliberately. The firmware half needs
@# `source ~/esp-idf/export.sh` in every shell (see the firmware gotchas
@# above); a Makefile recipe runs in its own subshell, so it cannot leave
@# that sourced in the caller's shell. A `setup` that appeared to prepare
@# firmware and silently left `idf.py` unresolved would be worse than one
@# that says plainly it does not touch that half — hence `build-firmware`
@# sources export.sh itself, per target, instead.
@#
@# Exit 0 from `pip install` is not evidence (D-24) — pip reports success
@# even when the result is unusable (e.g. a dependency that resolved but
@# doesn't actually import, or a stale .venv left over from a different
@# Python). Prove the environment works instead of trusting the install
@# step: `--collect-only` imports every test module and therefore every
@# src module each one pulls in (T-47). It runs zero tests, so it stays
@# cheap, and it also confirms ruff/mypy landed in .venv/bin — the venv
@# is the only place either binary exists (see gateway gotchas above);
@# `--version` is enough to prove each resolves and runs.
cd $(GATEWAY) && .venv/bin/python -m pytest tests/ --collect-only -q
cd $(GATEWAY) && .venv/bin/ruff --version >/dev/null
cd $(GATEWAY) && .venv/bin/mypy --version >/dev/null
.PHONY: setup-speech .PHONY: setup-speech
setup-speech: ## Additionally install faster-whisper and piper setup-speech: ## Additionally install faster-whisper and piper
+22
View File
@@ -33,6 +33,28 @@ where = ["src"]
line-length = 100 line-length = 100
src = ["src"] src = ["src"]
# Selected explicitly, because the default set is not a constant.
#
# With no `select` here, ruff lints with whatever its installed version
# defaults to — 413 rules under 0.16.3. `dev` pins only `ruff>=0.6`, and CI
# installs that extra fresh on every run, so the gate's scope was a function of
# when pip last resolved rather than of this code. Two findings appeared here
# the first time a converged environment ran the gate, in a file nobody had
# touched (T-47).
#
# That is the failure this repo keeps meeting from the other side: a check
# whose result depends on something other than the thing it checks. Pinning the
# ruff version would freeze the symptom; naming the rules fixes it, and makes
# a future ruff release a decision rather than a surprise.
#
# ASYNC is here on purpose — this is a websocket gateway, and it is the one
# family whose findings would be genuine bugs rather than style.
# BLE (blind except) is deliberately absent: main.py catches bare Exception
# when a device disappears mid-send, which is correct there and would need a
# noqa on every occurrence to say so.
[tool.ruff.lint]
select = ["E", "W", "F", "I", "UP", "B", "ASYNC", "SIM", "C4"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
asyncio_mode = "auto" asyncio_mode = "auto"
testpaths = ["tests"] testpaths = ["tests"]
+2 -2
View File
@@ -7,7 +7,7 @@ Protocol (see docs/architecture.md — keep in sync):
import asyncio import asyncio
import logging import logging
from datetime import datetime, timezone from datetime import UTC, datetime
from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi import FastAPI, WebSocket, WebSocketDisconnect
@@ -41,7 +41,7 @@ async def _ensure_filler() -> bytes | None:
def _now() -> str: def _now() -> str:
return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds") return datetime.now(UTC).astimezone().isoformat(timespec="seconds")
@app.get("/healthz") @app.get("/healthz")