davinci-resolve-mcp 2.54.4 → 2.54.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,32 @@
2
2
 
3
3
  Release history for the DaVinci Resolve MCP Server. The latest release is summarized in the root README; older entries live here to keep the README focused.
4
4
 
5
+ ## What's New in v2.54.5
6
+
7
+ Reliability + security hardening from the exhaustive reliability audit (Wave A).
8
+
9
+ - **Security** `apply_spec` hooks no longer run with `shell=True`. A spec's hook
10
+ command is now `shlex`-split and executed without a shell, so a hook string
11
+ can't inject arbitrary shell (`; rm -rf …`, pipes, expansion). Hooks needing
12
+ shell features must invoke an interpreter explicitly (e.g. `bash -c "…"`).
13
+ - **Fixed** the confirm-token table (`_CONFIRM_TOKENS`) is now guarded by a lock.
14
+ The control panel is threaded, so issue/consume/GC ran concurrently; validate-
15
+ then-pop is now atomic and GC can't race a write.
16
+ - **Fixed** negative item indices are rejected instead of silently returning the
17
+ wrong item (Python reverse-indexing): `_get_item`, the audio item resolver, and
18
+ the two timeline-matte helpers now require `0 <= index < len`.
19
+ - **Fixed** history queries clamp `limit` to `[1, 1000]`. SQLite treats a negative
20
+ `LIMIT` as "no limit", so a negative value could silently fetch the entire table
21
+ (`get_brain_edit_history`, `list_runs`, `get_media_pool_change_history`).
22
+ `timeline_versioning` version/limit/keep_n params are now validated (no unhandled
23
+ `ValueError`; `rollback` rejects negative versions before archiving). `max_files`
24
+ /`max_seconds` in the relink file search are clamped positive.
25
+ - **Fixed** the server-reachable ffmpeg probes (render, audio, review) now pass
26
+ `timeout=120` so a hung ffmpeg can't block the server indefinitely.
27
+
28
+ (Deferred to a live-validated follow-up: confirm-token gating + archiving for
29
+ catastrophic media-pool/take/fusion deletes, and temp-directory lifecycle cleanup.)
30
+
5
31
  ## What's New in v2.54.4
6
32
 
7
33
  Persistence-safety hardening (generalizes issue #71 to the analysis state
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # DaVinci Resolve MCP Server
2
2
 
3
- [![Version](https://img.shields.io/badge/version-2.54.4-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.54.5-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
4
4
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
5
5
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
6
6
  [![Tools](https://img.shields.io/badge/MCP%20Tools-34%20(341%20full)-blue.svg)](#server-modes)
package/install.py CHANGED
@@ -35,7 +35,7 @@ from src.utils.update_check import (
35
35
 
36
36
  # ─── Version ──────────────────────────────────────────────────────────────────
37
37
 
38
- VERSION = "2.54.4"
38
+ VERSION = "2.54.5"
39
39
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
40
40
  # Resolve's scripting bridge loads into newer interpreters on recent builds
41
41
  # (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.54.4",
3
+ "version": "2.54.5",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
80
80
  handlers=[logging.StreamHandler()],
81
81
  )
82
82
 
83
- VERSION = "2.54.4"
83
+ VERSION = "2.54.5"
84
84
  logger = logging.getLogger("davinci-resolve-mcp")
85
85
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
86
86
  logger.info(f"Detected platform: {get_platform()}")
@@ -574,7 +574,7 @@ def get_timeline_matte_list(item_index: int = 0, track_type: str = "video", trac
574
574
  if not tl:
575
575
  return {"error": "No current timeline"}
576
576
  items = tl.GetItemListInTrack(track_type, track_index)
577
- if not items or item_index >= len(items):
577
+ if not items or item_index < 0 or item_index >= len(items): # reject negatives (EX5)
578
578
  return {"error": f"No item at index {item_index}"}
579
579
  mattes = mp.GetTimelineMatteList(items[item_index])
580
580
  return {"mattes": mattes if mattes else []}
@@ -171,7 +171,7 @@ def add_timeline_mattes_to_media_pool(timeline_item_index: int, matte_paths: Lis
171
171
  return {"error": "No current timeline"}
172
172
 
173
173
  items = timeline.GetItemListInTrack(track_type, track_index)
174
- if not items or timeline_item_index >= len(items):
174
+ if not items or timeline_item_index < 0 or timeline_item_index >= len(items): # reject negatives (EX5)
175
175
  return {"error": f"Timeline item at index {timeline_item_index} not found"}
176
176
 
177
177
  item = items[timeline_item_index]
package/src/server.py CHANGED
@@ -11,7 +11,7 @@ Usage:
11
11
  python src/server.py --full # Start the 341-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.54.4"
14
+ VERSION = "2.54.5"
15
15
 
16
16
  import base64
17
17
  import os
@@ -1006,6 +1006,10 @@ import uuid as _uuid
1006
1006
 
1007
1007
 
1008
1008
  _CONFIRM_TOKENS: Dict[str, Dict[str, Any]] = {}
1009
+ # The control panel runs on a threaded HTTP server, so issue/consume/gc of the
1010
+ # token table run on concurrent threads. Guard every access so a GC iteration
1011
+ # can't race a write and validate-then-pop stays atomic (EX4).
1012
+ _CONFIRM_TOKENS_LOCK = threading.RLock()
1009
1013
  _CONFIRM_TTL_SECONDS = 300
1010
1014
 
1011
1015
 
@@ -1023,11 +1027,13 @@ def _confirm_token_fingerprint(action: str, params: Optional[Dict[str, Any]]) ->
1023
1027
 
1024
1028
 
1025
1029
  def _confirm_token_gc():
1026
- """Drop expired tokens; called on every issue/validate."""
1030
+ """Drop expired tokens; called on every issue/validate. Caller may already
1031
+ hold _CONFIRM_TOKENS_LOCK (RLock makes re-entry safe)."""
1027
1032
  now = _time.time()
1028
- expired = [t for t, rec in _CONFIRM_TOKENS.items() if rec.get("expires_at", 0) < now]
1029
- for t in expired:
1030
- _CONFIRM_TOKENS.pop(t, None)
1033
+ with _CONFIRM_TOKENS_LOCK:
1034
+ expired = [t for t, rec in _CONFIRM_TOKENS.items() if rec.get("expires_at", 0) < now]
1035
+ for t in expired:
1036
+ _CONFIRM_TOKENS.pop(t, None)
1031
1037
 
1032
1038
 
1033
1039
  def _confirm_token_required() -> bool:
@@ -1045,16 +1051,17 @@ def _confirm_token_required() -> bool:
1045
1051
 
1046
1052
  def _issue_confirm_token(*, action: str, params: Optional[Dict[str, Any]], preview: Dict[str, Any]) -> Dict[str, Any]:
1047
1053
  """Mint a token. Returns the pending_user_decision response shape."""
1048
- _confirm_token_gc()
1049
1054
  token = _uuid.uuid4().hex
1050
1055
  fp = _confirm_token_fingerprint(action, params)
1051
1056
  expires_at = _time.time() + _CONFIRM_TTL_SECONDS
1052
- _CONFIRM_TOKENS[token] = {
1053
- "action": action,
1054
- "fingerprint": fp,
1055
- "expires_at": expires_at,
1056
- "issued_at": _time.time(),
1057
- }
1057
+ with _CONFIRM_TOKENS_LOCK:
1058
+ _confirm_token_gc()
1059
+ _CONFIRM_TOKENS[token] = {
1060
+ "action": action,
1061
+ "fingerprint": fp,
1062
+ "expires_at": expires_at,
1063
+ "issued_at": _time.time(),
1064
+ }
1058
1065
  body = _err(
1059
1066
  f"This action is destructive. Re-call with confirm_token to proceed.",
1060
1067
  code="CONFIRMATION_REQUIRED",
@@ -1082,8 +1089,9 @@ def _consume_confirm_token(*, action: str, params: Optional[Dict[str, Any]]) ->
1082
1089
  token = (params or {}).get("confirm_token") or (params or {}).get("confirmToken")
1083
1090
  if not token:
1084
1091
  return None # Caller is expected to call _issue_confirm_token in this case.
1085
- _confirm_token_gc()
1086
- rec = _CONFIRM_TOKENS.pop(token, None) # one-time use
1092
+ with _CONFIRM_TOKENS_LOCK:
1093
+ _confirm_token_gc()
1094
+ rec = _CONFIRM_TOKENS.pop(token, None) # one-time use, atomic with gc
1087
1095
  if rec is None:
1088
1096
  return _err(
1089
1097
  "confirm_token is invalid, expired, or was issued by a different "
@@ -1855,7 +1863,9 @@ def _get_item(p):
1855
1863
  track_index = p.get("track_index", 1)
1856
1864
  item_index = p.get("item_index", 0)
1857
1865
  items = tl.GetItemListInTrack(track_type, track_index)
1858
- if not items or item_index >= len(items):
1866
+ # Reject negatives explicitly: Python's reverse-indexing would otherwise
1867
+ # silently return the wrong item for item_index < 0 (EX5).
1868
+ if not items or not isinstance(item_index, int) or item_index < 0 or item_index >= len(items):
1859
1869
  return tl, None, _err(
1860
1870
  f"No item at index {item_index} on {track_type} track {track_index}",
1861
1871
  code="ITEM_INDEX_OUT_OF_RANGE", category="invalid_input",
@@ -5494,8 +5504,10 @@ def _bounded_basename_matches(
5494
5504
  p: Dict[str, Any],
5495
5505
  ) -> Tuple[List[str], Dict[str, Any]]:
5496
5506
  started = time.monotonic()
5497
- max_seconds = float(p.get("max_seconds", p.get("timeout_seconds", 20)) or 20)
5498
- max_files = int(p.get("max_files_scanned", p.get("maxFilesScanned", 50000)) or 50000)
5507
+ # Clamp to positive: a negative max_seconds/max_files would trip the
5508
+ # >= guards on the first iteration and silently return no results (EX8).
5509
+ max_seconds = max(0.1, float(p.get("max_seconds", p.get("timeout_seconds", 20)) or 20))
5510
+ max_files = max(1, int(p.get("max_files_scanned", p.get("maxFilesScanned", 50000)) or 50000))
5499
5511
  max_depth_raw = p.get("max_depth", p.get("maxDepth"))
5500
5512
  max_depth = int(max_depth_raw) if max_depth_raw is not None else None
5501
5513
  all_matches = bool(p.get("all_matches", False))
@@ -5704,7 +5716,7 @@ def _audio_item_from_params(tl, p: Dict[str, Any]):
5704
5716
  track_index = int(p.get("track_index", 1))
5705
5717
  item_index = int(p.get("item_index", 0))
5706
5718
  items = tl.GetItemListInTrack(track_type, track_index) or []
5707
- if item_index >= len(items):
5719
+ if item_index < 0 or item_index >= len(items): # reject negatives (EX5)
5708
5720
  return None, _err(f"No item at index {item_index} on {track_type} track {track_index}")
5709
5721
  return items[item_index], None
5710
5722
 
@@ -7474,6 +7486,23 @@ def _setup_positive_int(value: Any, default: int, min_value: int = 0, max_value:
7474
7486
  return max(min_value, min(parsed, max_value))
7475
7487
 
7476
7488
 
7489
+ def _safe_int(value: Any, default: int, *, minimum: Optional[int] = None, maximum: Optional[int] = None) -> int:
7490
+ """Coerce a tool param to int without throwing; clamp to [minimum, maximum].
7491
+
7492
+ Used by action handlers so a non-numeric or out-of-range numeric param yields
7493
+ a sane value instead of an unhandled ValueError or a SQLite "negative LIMIT =
7494
+ no limit" full-table fetch (EX8)."""
7495
+ try:
7496
+ parsed = int(value)
7497
+ except (TypeError, ValueError):
7498
+ parsed = default
7499
+ if minimum is not None:
7500
+ parsed = max(minimum, parsed)
7501
+ if maximum is not None:
7502
+ parsed = min(parsed, maximum)
7503
+ return parsed
7504
+
7505
+
7477
7506
  def _setup_marker_limit(value: Any, default: int = 12) -> int:
7478
7507
  if isinstance(value, str) and _setup_text_key(value) in {"unlimited", "no_limit", "nolimit", "all"}:
7479
7508
  return 0
@@ -13673,14 +13702,29 @@ class _SpecLiveExecutor:
13673
13702
 
13674
13703
 
13675
13704
  def _make_spec_hook_runner(timeout: float = 120.0):
13676
- """Return a callable that runs a Hook's shell command (opt-in only)."""
13705
+ """Return a callable that runs a Hook's command (opt-in only).
13706
+
13707
+ The command is split with shlex and run WITHOUT a shell, so a spec's hook
13708
+ string cannot inject arbitrary shell (`; rm -rf …`, pipes, expansion). A
13709
+ hook that needs shell features must invoke an explicit interpreter
13710
+ (e.g. ``bash -c "…"``) as its own argv, making the intent visible (EX1).
13711
+ """
13712
+ import shlex
13677
13713
  import subprocess
13678
13714
 
13679
13715
  def _run(hook) -> bool:
13716
+ try:
13717
+ argv = shlex.split(hook.command or "")
13718
+ except ValueError as exc:
13719
+ logger.warning("spec hook '%s' has an unparseable command: %s", hook.name or hook.command, exc)
13720
+ return False
13721
+ if not argv:
13722
+ logger.warning("spec hook '%s' has an empty command", hook.name or "")
13723
+ return False
13680
13724
  try:
13681
13725
  proc = subprocess.run(
13682
- hook.command,
13683
- shell=True,
13726
+ argv,
13727
+ shell=False,
13684
13728
  timeout=timeout,
13685
13729
  stdin=subprocess.DEVNULL,
13686
13730
  capture_output=True,
@@ -15965,7 +16009,7 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
15965
16009
  _r, _proj, project_root, _name = vctx
15966
16010
  session_only = bool(p.get("session_only", False))
15967
16011
  session_id = _AI_LEDGER_SESSION_ID if session_only else None
15968
- limit = int(p.get("limit", 50))
16012
+ limit = _safe_int(p.get("limit"), 50, minimum=1, maximum=1000)
15969
16013
  return {
15970
16014
  "success": True,
15971
16015
  "session_id": _AI_LEDGER_SESSION_ID,
@@ -16821,7 +16865,7 @@ def timeline_versioning(action: str, params: Optional[Dict[str, Any]] = None) ->
16821
16865
  if action == "list_runs":
16822
16866
  return {
16823
16867
  "success": True,
16824
- "runs": _analysis_runs.list_runs(project_root, limit=int(p.get("limit", 50))),
16868
+ "runs": _analysis_runs.list_runs(project_root, limit=_safe_int(p.get("limit"), 50, minimum=1, maximum=1000)),
16825
16869
  }
16826
16870
  if action == "archive_current":
16827
16871
  return _timeline_versioning.archive_current_timeline(
@@ -16842,13 +16886,18 @@ def timeline_versioning(action: str, params: Optional[Dict[str, Any]] = None) ->
16842
16886
  if action == "diff_versions":
16843
16887
  if not p.get("timeline_name") or "from_version" not in p or "to_version" not in p:
16844
16888
  return _err("timeline_name, from_version, to_version required")
16889
+ try:
16890
+ from_version = int(p["from_version"])
16891
+ to_version = int(p["to_version"])
16892
+ except (TypeError, ValueError):
16893
+ return _err("from_version and to_version must be integers")
16845
16894
  return {
16846
16895
  "success": True,
16847
16896
  **_timeline_versioning.diff_versions(
16848
16897
  project_root=project_root,
16849
16898
  timeline_name=str(p["timeline_name"]),
16850
- from_version=int(p["from_version"]),
16851
- to_version=int(p["to_version"]),
16899
+ from_version=from_version,
16900
+ to_version=to_version,
16852
16901
  ),
16853
16902
  }
16854
16903
  if action == "diff_timelines":
@@ -16864,18 +16913,24 @@ def timeline_versioning(action: str, params: Optional[Dict[str, Any]] = None) ->
16864
16913
  project_root=project_root,
16865
16914
  timeline_name=p.get("timeline_name"),
16866
16915
  analysis_run_id=p.get("analysis_run_id"),
16867
- limit=int(p.get("limit", 50)),
16916
+ limit=_safe_int(p.get("limit"), 50, minimum=1, maximum=1000),
16868
16917
  )
16869
16918
  return {"success": True, "edits": rows}
16870
16919
  if action == "rollback":
16871
16920
  if not p.get("timeline_name") or "version" not in p:
16872
16921
  return _err("timeline_name and version required")
16922
+ try:
16923
+ version = int(p["version"])
16924
+ except (TypeError, ValueError):
16925
+ return _err("version must be an integer")
16926
+ if version < 0:
16927
+ return _err("version must be >= 0")
16873
16928
  return _timeline_versioning.rollback_to_version(
16874
16929
  resolve=resolve_h,
16875
16930
  project=project_h,
16876
16931
  project_root=project_root,
16877
16932
  timeline_name=str(p["timeline_name"]),
16878
- version=int(p["version"]),
16933
+ version=version,
16879
16934
  analysis_run_id=p.get("analysis_run_id"),
16880
16935
  )
16881
16936
  if action == "prune":
@@ -16886,7 +16941,7 @@ def timeline_versioning(action: str, params: Optional[Dict[str, Any]] = None) ->
16886
16941
  project=project_h,
16887
16942
  project_root=project_root,
16888
16943
  timeline_name=str(p["timeline_name"]),
16889
- keep_n=int(p.get("keep_n", 10)),
16944
+ keep_n=_safe_int(p.get("keep_n"), 10, minimum=1, maximum=1000),
16890
16945
  )
16891
16946
  if action == "registry":
16892
16947
  return {"success": True, **_brain_edits.read_brain_edits_registry(project_root)}
@@ -16897,7 +16952,7 @@ def timeline_versioning(action: str, params: Optional[Dict[str, Any]] = None) ->
16897
16952
  project_root=project_root,
16898
16953
  analysis_run_id=p.get("analysis_run_id"),
16899
16954
  action=p.get("media_pool_action"),
16900
- limit=int(p.get("limit", 50)),
16955
+ limit=_safe_int(p.get("limit"), 50, minimum=1, maximum=1000),
16901
16956
  ),
16902
16957
  }
16903
16958
  return _err(f"Unknown action: {action}")
@@ -234,6 +234,11 @@ def get_run(project_root: str, analysis_run_id: str) -> Optional[Dict[str, Any]]
234
234
 
235
235
 
236
236
  def list_runs(project_root: str, *, limit: int = 50) -> List[Dict[str, Any]]:
237
+ # SQLite treats a negative LIMIT as "no limit"; clamp (EX8).
238
+ try:
239
+ limit = max(1, min(1000, int(limit)))
240
+ except (TypeError, ValueError):
241
+ limit = 50
237
242
  conn = timeline_brain_db.connect(project_root)
238
243
  rows = conn.execute(
239
244
  """
@@ -47,7 +47,7 @@ def _record_tool_result(
47
47
 
48
48
 
49
49
  def _run_ffmpeg(args: list[str]) -> None:
50
- subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", *args], check=True)
50
+ subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", *args], check=True, timeout=120)
51
51
 
52
52
 
53
53
  def _make_synthetic_media(work_dir: Path) -> Dict[str, Path]:
@@ -278,6 +278,12 @@ def get_brain_edit_history(
278
278
  limit: int = 50,
279
279
  ) -> List[Dict[str, Any]]:
280
280
  """Return brain_edit rows, optionally filtered by timeline or run, newest first."""
281
+ # SQLite treats a negative LIMIT as "no limit"; clamp so a negative/huge
282
+ # limit can't silently fetch the whole table (EX8).
283
+ try:
284
+ limit = max(1, min(1000, int(limit)))
285
+ except (TypeError, ValueError):
286
+ limit = 50
281
287
  conn = timeline_brain_db.connect(project_root)
282
288
  clauses: List[str] = []
283
289
  args: List[Any] = []
@@ -84,6 +84,11 @@ def get_media_pool_change_history(
84
84
  action: Optional[str] = None,
85
85
  limit: int = 50,
86
86
  ) -> List[Dict[str, Any]]:
87
+ # SQLite treats a negative LIMIT as "no limit"; clamp (EX8).
88
+ try:
89
+ limit = max(1, min(1000, int(limit)))
90
+ except (TypeError, ValueError):
91
+ limit = 50
87
92
  conn = timeline_brain_db.connect(project_root)
88
93
  clauses: List[str] = []
89
94
  args: List[Any] = []
@@ -59,7 +59,7 @@ def _record_tool_result(
59
59
 
60
60
 
61
61
  def _run_ffmpeg(args: list[str]) -> None:
62
- subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", *args], check=True)
62
+ subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", *args], check=True, timeout=120)
63
63
 
64
64
 
65
65
  def _make_synthetic_video(work_dir: Path) -> Path:
@@ -59,7 +59,7 @@ def _record_tool_result(
59
59
 
60
60
 
61
61
  def _run_ffmpeg(args: list[str]) -> None:
62
- subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", *args], check=True)
62
+ subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", *args], check=True, timeout=120)
63
63
 
64
64
 
65
65
  def _make_synthetic_video(work_dir: Path) -> Path: