Skip to content

Commit 48a412b

Browse files
MrReasonableclaude
andauthored
fix: give the fifteen hand-built album paths the guard resolve_path has (#534)
Extracts the guarded album-path core into handlers/_shared._album_dir, beside _normalize_slug and _is_path_confined, so the helper a caller reaches for is the one carrying the guards. Replaces fifteen hand-built album paths across six handler modules; _genre_dir/_albums_dir cover the two truncations. Only resolve_path ever applied the resolved confinement check, so confine is a per-call-site decision tracking each site's prior behaviour — True at resolve_path, False at the sites operating on an album directory that already exists, which may legitimately be a symlink pointing outside its root (test_symlinked_audio_dir_passes). The lexical traversal guard always applies. _album_dir raises, and each call site reports the failure per its own contract: _resolve_audio_dir returns its (error_json, None) tuple, update_album_status adds a release_issues entry, and migrate_audio_layout records a per-album skip so one unresolvable album cannot discard the report of files already moved. resolve_path's output is unchanged except one deliberate tightening: genre='..' is now rejected rather than returning …/albums/../al. Adds 30 tests across tests/unit/handlers/test_album_paths.py and test_album_path_call_sites.py. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 10a8d69 commit 48a412b

9 files changed

Lines changed: 577 additions & 35 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ This project uses [Conventional Commits](https://conventionalcommits.org/) and [
1010
- **The lint/type-check toolchain is pinned exactly instead of floating** ([#532](https://github.com/bitwize-music-studio/claude-ai-music-skills/issues/532)) — `requirements.txt` pinned all 16 runtime deps with `==`, but every entry in `requirements-test.txt` used `>=`, so `ruff`, `mypy` and `bandit` resolved to whatever was newest on PyPI at the moment CI ran. Those three decide the Lint and Security Scan verdicts, and unlike a test runner they change their answer on unchanged code — a new rule or a widened check reddens a commit nobody touched, and re-running an old green build no longer reproduces it. The drift was already visible: the file read `ruff>=0.15.21` while CI had been installing `0.16.0`, which is why Dependabot closed #522 as redundant. `ruff`, `mypy` and `bandit` are now `==` pins (`cache: 'pip'` never mitigated this — it caches wheels, but pip still resolves to newest). The `pytest` stack stays on `>=`: it changes what runs, not what counts as a violation. A parametrized test in `tests/unit/shared/test_pinned_dependencies.py` keeps the three from silently loosening again.
1111

1212
### Fixed
13+
- **The album directory shape is written down once, and the callers that built it by hand now get the traversal guard** — `#529` removed `tools/shared/paths.py` on the grounds that it was the *unguarded* variant of path resolution and "the obvious helper for a contributor to reach for", leaving `handlers/core.py:resolve_path` as the single implementation. That diagnosis was right, but the duplication it was meant to prevent was already there and survived the removal: `Path(root) / "artists" / artist / "albums" / genre / slug` and its two truncations appeared at fifteen sites across six handler modules. The reason is structural rather than carelessness — `resolve_path` is an `async` MCP tool that returns a JSON string, so no library code can call it, and every caller that needed a path had no option but to respell it. Only `resolve_path` applied `_normalize_slug` and a confinement check; the rest interpolated straight in, which is the same weakness `#529` deleted a file over. The guarded core is now `handlers/_shared._album_dir`, living beside `_normalize_slug` and `_is_path_confined` so the helper a caller reaches for is the one that carries the guards, with `_genre_dir`/`_albums_dir` for the two truncations callers wanted. Still one implementation — just a callable one.
14+
- Two guards, not one, and they are not the same guard. A **lexical** pass always runs: it rejects a `..` or separator-carrying `artist`, `genre` or `subdir` *before* the layout is rendered, and rejects an absolute segment outright rather than silently relativizing it into a confined-but-wrong path. A **resolved** confinement check (`resolved.is_relative_to(root)`) is separate, because it is not a traversal guard — it also rejects a symlinked album directory, which is a *supported* layout (`test_symlinked_audio_dir_passes`). Only `resolve_path` ever applied it, so each call site passes the `confine` value matching what it did before centralising: `True` at `resolve_path`, `False` at the sites that operate on an album directory which already exists (`_resolve_audio_dir`, `update_album_status`, `migrate_audio_layout`, `rename_album`, `validate_album_structure`). It defaults to `True` so a *new* caller who forgets fails closed and loudly.
15+
- **`resolve_path`'s output is unchanged except for one deliberate tightening:** a `genre` of `..` used to return `…/albums/../al`, and is now rejected with the same `Resolved path escapes root directory` error every other escape returns. Every other input, including `genre="/etc"`, returns exactly what it did before.
16+
- `_album_dir` raises, so each call site catches it and reports the failure the way its own contract says: `_resolve_audio_dir` returns its `(error_json, None)` tuple rather than raising past the twenty-two call sites that branch on it; `update_album_status` adds a `release_issues` entry rather than collapsing the aggregated gate into one opaque error; and `migrate_audio_layout` records a per-album skip, so one unresolvable album cannot discard the report of files it has already physically moved into `originals/`.
1317
- **`tracks_completed` no longer flips between two different numbers depending on which code path last ran** ([#523](https://github.com/bitwize-music-studio/claude-ai-music-skills/issues/523)) — `reference/state-schema.md` defines the field as "Number of tracks with completed status", but only the incremental track-change path counted the track files. A full rebuild (`scan_albums`) and the incremental README-changed branch both counted the album README's `## Tracklist` table instead. Since `update_track_field` rewrites a track file without touching that table, the two drifted the moment a track's status changed: `rebuild_state()` — the documented remedy for a stale cache — discarded the correct count and reinstated the README's stale one, and editing only the README silently reset it. This also made `list_albums` and `get_album_progress` disagree about the same album, and made the CLI print a README-derived numerator over an actual-file denominator (a finished album could render as `[0/12 tracks]`). All three sites now derive the count from the track files through a single `_count_completed_tracks` helper. `parse_album_readme` still reports what the README table claims, but nothing feeds it into state.
1418
- **A corrupt `ideas` or `skills` section in state.json no longer crashes `python -m tools.state update`** ([#525](https://github.com/bitwize-music-studio/claude-ai-music-skills/issues/525)) — `incremental_update` type-guards its top-level state sections so a wrong-typed one returns `None` and the caller falls back to a full rebuild (the `#393` contract), but the guard only covered `config` and `albums`. The function also does `state['ideas'].get('file_mtime')` and `state['skills'].get('skills_root')`, so a non-mapping value in either raised `AttributeError` straight past `cmd_update`'s `is None` fallback and aborted the CLI with a traceback — the exact failure the guard exists to prevent. All four sections are now guarded; both are re-derived from disk on a rebuild, so falling back loses nothing.
1519
- **`.gitignore` no longer lists `TESTING.md` as a build artifact** ([#526](https://github.com/bitwize-music-studio/claude-ai-music-skills/issues/526)) — the entry sat in the "Build artifacts" block between `*.egg-info/` and `.coverage`, but `TESTING.md` is a tracked contributor doc referenced from `skills/test/test-definitions.md`. The rule was inert (`.gitignore` does not affect already-tracked files) but would have silently dropped the doc for anyone who ran `git rm --cached` or re-added it after a delete.

servers/bitwize-music-server/handlers/_shared.py

Lines changed: 134 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,123 @@ def _normalize_slug(name: str) -> str:
159159
return slug
160160

161161

162+
# The album directory shape, written down once. Every album mirrors this same
163+
# relative path under content_root, audio_root and documents_root.
164+
#
165+
# Two callers want the layout truncated rather than whole, so the segments above
166+
# ``{genre}`` are named first and the full shape is built from them. The
167+
# truncation is then a shared prefix rather than string surgery on a rendered
168+
# template, which would couple to the literal ``{genre}`` token.
169+
_ALBUMS_SEGMENTS = ("artists", "{artist}", "albums")
170+
ALBUM_LAYOUT_SEGMENTS = (*_ALBUMS_SEGMENTS, "{genre}", "{album}")
171+
ALBUM_LAYOUT = "/".join(ALBUM_LAYOUT_SEGMENTS)
172+
173+
PATH_ESCAPES_ROOT = "Resolved path escapes root directory"
174+
175+
176+
def _album_dir(
177+
root: str | Path,
178+
*,
179+
artist: str,
180+
genre: str,
181+
album: str,
182+
subdir: str = "",
183+
confine: bool = True,
184+
) -> Path:
185+
"""Resolve one album's directory under *root*, with the traversal guards applied.
186+
187+
This is the guarded resolution that ``core.py:resolve_path`` performs;
188+
``resolve_path`` is the MCP-tool wrapper around it. It lives here, beside
189+
``_normalize_slug`` and ``_is_path_confined``, so that the helper a caller
190+
reaches for is the one that carries the guards — #529 removed a
191+
``tools/shared/paths.py`` that interpolated the slug straight in, and the
192+
stated risk was precisely that a contributor would reach for the unguarded
193+
variant.
194+
195+
*root* is whichever of content_root, audio_root or documents_root is
196+
wanted; the shape below it is identical for all three.
197+
198+
Args:
199+
root: Root directory from config.
200+
artist: Artist name from config.
201+
genre: Genre slug.
202+
album: Album slug. Normalized here — callers need not pre-normalize,
203+
and passing an already-normalized slug is idempotent.
204+
subdir: Optional child directory, e.g. ``"tracks"``. Included in the
205+
confinement check rather than appended after it.
206+
confine: Also require the *resolved* path to stay within *root*.
207+
208+
This is a per-call-site decision, not a global one, because only
209+
``resolve_path`` ever applied it: pass what the site did before.
210+
``True`` at ``resolve_path``. ``False`` at the sites that operate on
211+
an album directory that already exists, because such a directory is
212+
allowed to be a symlink pointing outside its root
213+
(``test_symlinked_audio_dir_passes``) and resolving rejects that
214+
supported layout. The lexical guard below applies either way.
215+
216+
Defaults to ``True`` so a new caller who forgets fails closed and
217+
loudly — a break gets found, a silently dropped guard does not.
218+
219+
Returns:
220+
The resolved directory. Not created.
221+
222+
Raises:
223+
ValueError: *album* contains a path separator, a null byte or a
224+
traversal sequence (from ``_normalize_slug``); *artist*, *genre* or
225+
*subdir* is a traversal or carries a separator; or, under
226+
``confine``, the result escapes *root* despite all of that.
227+
"""
228+
normalized = _normalize_slug(album)
229+
230+
# Lexical guard, always on, and applied to the caller-supplied values
231+
# *before* the layout is rendered. _normalize_slug already rejects traversal
232+
# and separators in the album slug, but artist and genre come from config
233+
# and state without passing through it, and subdir is a caller literal.
234+
#
235+
# Checking before rendering rather than after matters: splitting the
236+
# rendered template would turn genre="/etc" into a bare "etc" segment and
237+
# hand back a confined-but-wrong path. develop rejected that input, and a
238+
# wrong path is a worse failure mode than an error.
239+
for value in (artist, genre, subdir):
240+
if value and (value == ".." or "/" in value or "\\" in value or "\0" in value):
241+
raise ValueError(PATH_ESCAPES_ROOT)
242+
243+
relative = ALBUM_LAYOUT.format(artist=artist, genre=genre, album=normalized)
244+
base = Path(root)
245+
for segment in [*relative.split("/"), subdir]:
246+
if not segment:
247+
# An empty genre collapses, exactly as Path("a") / "" always has.
248+
continue
249+
base = base / segment
250+
251+
# Resolved confinement, opt-out. This is the check resolve_path has always
252+
# applied, and it catches what the lexical pass cannot: a symlink *inside*
253+
# the album path that points outside the root.
254+
if confine and not base.resolve().is_relative_to(Path(root).resolve()):
255+
raise ValueError(PATH_ESCAPES_ROOT)
256+
257+
return base
258+
259+
260+
def _albums_dir(root: str | Path, *, artist: str) -> Path:
261+
"""Directory holding every genre for one artist — the layout above ``{genre}``.
262+
263+
Used by the callers that sweep across genres, because album slugs are
264+
globally unique rather than unique per genre (#392).
265+
266+
``artist`` is trusted config — it is the same component ``_album_dir``
267+
guards, and is unguarded here only because it comes from the user's own
268+
``artist_name`` rather than from a tool argument. Callers that append an
269+
album slug use ``_is_path_confined`` or ``_album_dir``.
270+
"""
271+
return Path(root).joinpath(*(s.format(artist=artist) for s in _ALBUMS_SEGMENTS))
272+
273+
274+
def _genre_dir(root: str | Path, *, artist: str, genre: str) -> Path:
275+
"""Directory holding every album of one genre — ``_album_dir``'s parent."""
276+
return _albums_dir(root, artist=artist) / genre
277+
278+
162279
def _json_sanitize(value: Any) -> Any:
163280
"""Recursively replace non-finite floats (inf/-inf/nan) with None.
164281
@@ -556,7 +673,23 @@ def _resolve_audio_dir(album_slug: str, subfolder: str = "") -> tuple[str | None
556673
return _safe_json({
557674
"error": f"Genre not found for album '{album_slug}'. Ensure album exists in state.",
558675
}), None
559-
audio_path = Path(audio_root) / "artists" / artist / "albums" / genre / normalized
676+
# confine=False preserves this funnel's prior behaviour: it never had a
677+
# resolved check, and an album's audio directory is allowed to be a symlink
678+
# pointing outside audio_root — the layout validate_album_structure passes
679+
# (test_symlinked_audio_dir_passes) must resolve here too, since every audio
680+
# tool (master, polish, qc, transcribe, promo, sheet music) comes through
681+
# this function. The lexical traversal guard still applies.
682+
#
683+
# The catch is the contract: this returns (error_json_or_None, Path_or_None),
684+
# and every caller branches on the first element. A raise would skip all of
685+
# those structured error paths, exactly as the _normalize_slug catch above
686+
# exists to prevent.
687+
try:
688+
audio_path = _album_dir(
689+
audio_root, artist=artist, genre=genre, album=normalized, confine=False,
690+
)
691+
except ValueError as exc:
692+
return _safe_json({"error": str(exc)}), None
560693
if subfolder:
561694
if not _is_path_confined(audio_path, subfolder):
562695
return _safe_json({

servers/bitwize-music-server/handlers/album_ops.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,14 @@
1414
_SECTION_NAMES,
1515
STATUS_UNKNOWN,
1616
TRACK_COMPLETED_STATUSES,
17+
_album_dir,
18+
_albums_dir,
1719
_extract_code_block,
1820
_extract_markdown_section,
1921
_find_album_or_error,
2022
_find_slug_dirs,
2123
_find_wav_source_dir,
24+
_genre_dir,
2225
_get_valid_genres,
2326
_is_path_confined,
2427
_normalize_slug,
@@ -202,7 +205,18 @@ async def validate_album_structure(
202205
artist = config.get("artist_name", "")
203206
album_path = album.get("path", "")
204207
genre = album.get("genre", "")
205-
audio_path = str(Path(audio_root) / "artists" / artist / "albums" / genre / normalized)
208+
# confine=False: an album's audio directory is allowed to be a symlink
209+
# pointing outside audio_root (test_symlinked_audio_dir_passes). This is a
210+
# read-only existence check, and the lexical traversal guard still applies.
211+
#
212+
# Caught rather than raised: this handler returns a JSON report, and a raise
213+
# would discard the checks already accumulated.
214+
try:
215+
audio_path = str(_album_dir(
216+
audio_root, artist=artist, genre=genre, album=normalized, confine=False,
217+
))
218+
except ValueError as exc:
219+
return _safe_json({"error": str(exc)})
206220

207221
passed = 0
208222
failed = 0
@@ -375,7 +389,7 @@ async def create_album_structure(
375389
"generation.additional_genres in config.",
376390
})
377391

378-
albums_base = Path(content_root) / "artists" / artist / "albums" / genre_slug
392+
albums_base = _genre_dir(content_root, artist=artist, genre=genre_slug)
379393
# Defense-in-depth: verify slug stays within the genre directory
380394
if not _is_path_confined(albums_base, normalized):
381395
return _safe_json({"error": "Invalid album slug: would escape album directory"})
@@ -386,7 +400,7 @@ async def create_album_structure(
386400

387401
# Album slugs are globally unique across genres (#392) — sweep the
388402
# filesystem (cache may be stale) for the slug under every genre.
389-
albums_root = Path(content_root) / "artists" / artist / "albums"
403+
albums_root = _albums_dir(content_root, artist=artist)
390404
collisions = _find_slug_dirs(albums_root, normalized)
391405
if collisions:
392406
existing = collisions[0]

servers/bitwize-music-server/handlers/core.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
TRACK_GENERATED,
2424
TRACK_IN_PROGRESS,
2525
TRACK_NOT_STARTED,
26+
_album_dir,
2627
_extract_code_block,
2728
_extract_markdown_section,
2829
_find_track_or_error,
@@ -690,14 +691,19 @@ async def resolve_path(path_type: str, album_slug: str, genre: str = "") -> str:
690691
"audio": audio_root,
691692
"documents": documents_root,
692693
}
693-
root_dir = Path(root_map[path_type]).resolve()
694-
base = Path(root_map[path_type]) / "artists" / artist / "albums" / genre / normalized
695-
if path_type == "tracks":
696-
base = base / "tracks"
697-
698-
# Defense-in-depth: verify resolved path stays within its root directory
699-
if not base.resolve().is_relative_to(root_dir):
700-
return _safe_json({"error": "Resolved path escapes root directory"})
694+
try:
695+
base = _album_dir(
696+
root_map[path_type],
697+
artist=artist,
698+
genre=genre,
699+
album=normalized,
700+
subdir="tracks" if path_type == "tracks" else "",
701+
# Stated rather than inherited: this is the one site that has always
702+
# applied the resolved confinement check, and it must keep it.
703+
confine=True,
704+
)
705+
except ValueError as exc:
706+
return _safe_json({"error": str(exc)})
701707

702708
resolved = str(base)
703709

servers/bitwize-music-server/handlers/maintenance.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from typing import Any
99

1010
from handlers import _shared
11-
from handlers._shared import _normalize_slug, _resolve_audio_dir, _safe_json
11+
from handlers._shared import _album_dir, _normalize_slug, _resolve_audio_dir, _safe_json
1212

1313
logger = logging.getLogger(__name__)
1414

@@ -221,7 +221,27 @@ async def migrate_audio_layout(
221221
skipped_count += 1
222222
continue
223223

224-
audio_dir = Path(audio_root) / "artists" / artist / "albums" / genre / slug
224+
# confine=False: this site never had a resolved check, and it operates on
225+
# album directories that already exist — which may be symlinks pointing
226+
# outside audio_root. The lexical traversal guard still applies.
227+
#
228+
# Caught per album rather than allowed to propagate: the call sits inside
229+
# the loop, so in migrate-all mode a raise on album N would discard the
230+
# whole results report after files for albums 1..N-1 had already been
231+
# physically moved into originals/.
232+
try:
233+
audio_dir = _album_dir(
234+
audio_root, artist=artist, genre=genre, album=slug, confine=False,
235+
)
236+
except ValueError as exc:
237+
results.append({
238+
"slug": slug,
239+
"status": "skipped",
240+
"files_moved": [],
241+
"skip_reason": f"could not resolve audio path: {exc}",
242+
})
243+
skipped_count += 1
244+
continue
225245

226246
if not audio_dir.is_dir():
227247
results.append({

0 commit comments

Comments
 (0)