* fix(profiles): decouple design-profile save from TTS render (#476)
Saving a design voice profile forced a full TTS model load + inference to
render a deterministic identity sample. On a fresh model-less image (Docker
first-run) that 503'd, so the save failed. A secondary guard also rejected an
all-Auto design (empty instruct) with a 422.
Saving a design profile is now a pure persistence operation:
- The seed-42 identity sample render is attempted opportunistically but is
non-fatal — if the engine isn't ready the row is persisted with
ref_audio_path=NULL (sample pending). The row's vd_states + instruct already
make the voice fully usable (generation.py falls back to instruct-only
conditioning for design profiles with no ref audio).
- The sample is rendered lazily + cached on the first GET /profiles/{id}/audio
request; if the engine is still unavailable that path returns a precise
"model not ready — finish setup / download a model" 503.
- The all-Auto (empty-instruct) design is now saveable (vd_states still
required).
Adds tests/test_profile_design_save_decouple.py (top-level tests/, asyncio.run
per test) covering: design save with model unavailable creates the row instead
of 503-ing; all-Auto design is saveable; the pending sample materializes on
first /audio request. Updates the unification spec (docs-sync).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(profiles): contain profile-audio paths under VOICES_DIR (CodeQL CWE-22)
The lazy design-sample path was built as `os.path.join(VOICES_DIR,
f"{profile_id}.wav")` / `os.path.join(VOICES_DIR, audio_file)` where profile_id
is the request path param — CodeQL flagged 5 high-severity path-injection alerts
(profiles.py + the taint flowing into archetypes.py's torchaudio save). Add
`_safe_voice_path()` (basename + safe-char sanitise + realpath containment,
mirroring core.config.dub_seg_path) and route both the read and lazy-render
sites through it; a traversal id now 404s instead of escaping VOICES_DIR.
Regression test covers the containment guard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(profiles): use CodeQL-recognized path-injection guards (CWE-22)
The previous `_safe_voice_path()` helper was correct (basename + realpath
containment) but CodeQL's taint tracking didn't propagate the barrier through
the function return, so the 5 path-injection alerts persisted. Switch to guards
CodeQL recognizes, inline at each file-op site:
- validate `profile_id` against the generated-id charset (`[A-Za-z0-9_-]{1,64}`)
with `re.fullmatch` and 404 on mismatch (covers the `f"{profile_id}.wav"`
render path);
- read only `os.path.join(VOICES_DIR, os.path.basename(name))` so a stored/derived
filename is always a direct child of VOICES_DIR (covers the read + the taint
flowing into archetypes.py's torchaudio save).
Drop the helper. Test now asserts a traversal/separator/NUL profile_id 404s at
the guard. Same security property, recognized by CodeQL.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(profiles): inline realpath+commonpath containment for CodeQL (CWE-22)
CodeQL didn't recognize the earlier sanitizers — neither the helper (barrier
hidden behind a function return) nor os.path.basename / a cross-function regex
guard cleared the 5 path-injection alerts. Use the canonical, CodeQL-recognized
form INLINE at each file-op site: resolve the path with os.path.realpath (which
collapses any `..`) and confirm os.path.commonpath((base, path)) == base before
the read / the render, returning 404 / raising on escape. Same property the
helper had, now in a shape CodeQL's taint tracking follows. Keeps the profile_id
charset guard as defense-in-depth.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(profiles): route design-sample path through shared _voices_path guard (#476)
The inline realpath+commonpath containment in get_profile_audio and
_materialize_design_sample wasn't recognized by CodeQL as a path-injection
sanitizer (5 new high-severity py/path-injection alerts at the file-op sites,
incl. archetypes.py mkdir via the rendered Path). Both now reuse the existing
_voices_path() helper, which applies the os.path.basename() barrier plus
symlink-resolved containment — the same guard the consent endpoint uses and
that CodeQL already accepts. Behavior is unchanged: the DB columns only ever
hold bare {profile_id}.wav filenames, so basename() is a no-op here.
Tests: tests/test_profile_design_save_decouple, test_profile_unification,
test_profile_consent, test_archetype_blank_guard — 25 passed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>