davinci-resolve-mcp 2.54.4 → 2.55.0

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,55 @@
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.55.0
6
+
7
+ Governance for catastrophic Media Pool deletes (exhaustive audit EX2/EX3).
8
+
9
+ **Behavior change:** `media_pool` `delete_clips`, `delete_folders`, and
10
+ `delete_timelines` now require a confirm token, the same two-step handshake
11
+ `timeline.delete_track` already uses — the first call returns
12
+ `status: "confirmation_required"` with a `confirm_token` and a preview (count +
13
+ names); re-call with `params.confirm_token` to execute. Callers that disable
14
+ gating via the `destructive.require_confirm_token=false` preference are
15
+ unaffected.
16
+
17
+ - **Fixed** the destructive-action registry listed granular function names
18
+ (`delete_media_pool_clips`, `move_media_pool_folders`, …) that the compound
19
+ `media_pool` tool never dispatches, so `is_destructive()` returned False and
20
+ these catastrophic deletes silently skipped version-on-mutate archiving and
21
+ change logging. The registry now uses the real compound action strings, so the
22
+ deletes are archived/logged as intended.
23
+ - **Added** confirm-token gating + previews to the three deletes (EX3). The
24
+ registry fix also re-enables the existing media-pool change log for them.
25
+ - Live-validated against DaVinci Resolve Studio 21.0.0 (non-destructively): the
26
+ no-token call returns `confirmation_required` and deletes nothing.
27
+
28
+ ## What's New in v2.54.5
29
+
30
+ Reliability + security hardening from the exhaustive reliability audit (Wave A).
31
+
32
+ - **Security** `apply_spec` hooks no longer run with `shell=True`. A spec's hook
33
+ command is now `shlex`-split and executed without a shell, so a hook string
34
+ can't inject arbitrary shell (`; rm -rf …`, pipes, expansion). Hooks needing
35
+ shell features must invoke an interpreter explicitly (e.g. `bash -c "…"`).
36
+ - **Fixed** the confirm-token table (`_CONFIRM_TOKENS`) is now guarded by a lock.
37
+ The control panel is threaded, so issue/consume/GC ran concurrently; validate-
38
+ then-pop is now atomic and GC can't race a write.
39
+ - **Fixed** negative item indices are rejected instead of silently returning the
40
+ wrong item (Python reverse-indexing): `_get_item`, the audio item resolver, and
41
+ the two timeline-matte helpers now require `0 <= index < len`.
42
+ - **Fixed** history queries clamp `limit` to `[1, 1000]`. SQLite treats a negative
43
+ `LIMIT` as "no limit", so a negative value could silently fetch the entire table
44
+ (`get_brain_edit_history`, `list_runs`, `get_media_pool_change_history`).
45
+ `timeline_versioning` version/limit/keep_n params are now validated (no unhandled
46
+ `ValueError`; `rollback` rejects negative versions before archiving). `max_files`
47
+ /`max_seconds` in the relink file search are clamped positive.
48
+ - **Fixed** the server-reachable ffmpeg probes (render, audio, review) now pass
49
+ `timeout=120` so a hung ffmpeg can't block the server indefinitely.
50
+
51
+ (Deferred to a live-validated follow-up: confirm-token gating + archiving for
52
+ catastrophic media-pool/take/fusion deletes, and temp-directory lifecycle cleanup.)
53
+
5
54
  ## What's New in v2.54.4
6
55
 
7
56
  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.55.0-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.55.0"
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.55.0",
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.55.0"
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.55.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -784,6 +784,11 @@ _destructive_hook.register_preference_provider(_destructive_preference_provider)
784
784
  _TOKEN_GATED_DESTRUCTIVE_ACTIONS = frozenset({
785
785
  ("timeline", "delete_track"),
786
786
  ("timeline", "apply_cuts"),
787
+ # Catastrophic media-pool deletes (EX3): irreversibly destroy clips/folders/
788
+ # timelines. Gated like delete_track; also archive-on-mutate via the registry.
789
+ ("media_pool", "delete_clips"),
790
+ ("media_pool", "delete_folders"),
791
+ ("media_pool", "delete_timelines"),
787
792
  # Phase E edit-engine loops: plan → confirm → execute.
788
793
  ("edit_engine", "execute_selects"),
789
794
  ("edit_engine", "execute_tighten"),
@@ -1006,6 +1011,10 @@ import uuid as _uuid
1006
1011
 
1007
1012
 
1008
1013
  _CONFIRM_TOKENS: Dict[str, Dict[str, Any]] = {}
1014
+ # The control panel runs on a threaded HTTP server, so issue/consume/gc of the
1015
+ # token table run on concurrent threads. Guard every access so a GC iteration
1016
+ # can't race a write and validate-then-pop stays atomic (EX4).
1017
+ _CONFIRM_TOKENS_LOCK = threading.RLock()
1009
1018
  _CONFIRM_TTL_SECONDS = 300
1010
1019
 
1011
1020
 
@@ -1023,11 +1032,13 @@ def _confirm_token_fingerprint(action: str, params: Optional[Dict[str, Any]]) ->
1023
1032
 
1024
1033
 
1025
1034
  def _confirm_token_gc():
1026
- """Drop expired tokens; called on every issue/validate."""
1035
+ """Drop expired tokens; called on every issue/validate. Caller may already
1036
+ hold _CONFIRM_TOKENS_LOCK (RLock makes re-entry safe)."""
1027
1037
  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)
1038
+ with _CONFIRM_TOKENS_LOCK:
1039
+ expired = [t for t, rec in _CONFIRM_TOKENS.items() if rec.get("expires_at", 0) < now]
1040
+ for t in expired:
1041
+ _CONFIRM_TOKENS.pop(t, None)
1031
1042
 
1032
1043
 
1033
1044
  def _confirm_token_required() -> bool:
@@ -1045,16 +1056,17 @@ def _confirm_token_required() -> bool:
1045
1056
 
1046
1057
  def _issue_confirm_token(*, action: str, params: Optional[Dict[str, Any]], preview: Dict[str, Any]) -> Dict[str, Any]:
1047
1058
  """Mint a token. Returns the pending_user_decision response shape."""
1048
- _confirm_token_gc()
1049
1059
  token = _uuid.uuid4().hex
1050
1060
  fp = _confirm_token_fingerprint(action, params)
1051
1061
  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
- }
1062
+ with _CONFIRM_TOKENS_LOCK:
1063
+ _confirm_token_gc()
1064
+ _CONFIRM_TOKENS[token] = {
1065
+ "action": action,
1066
+ "fingerprint": fp,
1067
+ "expires_at": expires_at,
1068
+ "issued_at": _time.time(),
1069
+ }
1058
1070
  body = _err(
1059
1071
  f"This action is destructive. Re-call with confirm_token to proceed.",
1060
1072
  code="CONFIRMATION_REQUIRED",
@@ -1082,8 +1094,9 @@ def _consume_confirm_token(*, action: str, params: Optional[Dict[str, Any]]) ->
1082
1094
  token = (params or {}).get("confirm_token") or (params or {}).get("confirmToken")
1083
1095
  if not token:
1084
1096
  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
1097
+ with _CONFIRM_TOKENS_LOCK:
1098
+ _confirm_token_gc()
1099
+ rec = _CONFIRM_TOKENS.pop(token, None) # one-time use, atomic with gc
1087
1100
  if rec is None:
1088
1101
  return _err(
1089
1102
  "confirm_token is invalid, expired, or was issued by a different "
@@ -1855,7 +1868,9 @@ def _get_item(p):
1855
1868
  track_index = p.get("track_index", 1)
1856
1869
  item_index = p.get("item_index", 0)
1857
1870
  items = tl.GetItemListInTrack(track_type, track_index)
1858
- if not items or item_index >= len(items):
1871
+ # Reject negatives explicitly: Python's reverse-indexing would otherwise
1872
+ # silently return the wrong item for item_index < 0 (EX5).
1873
+ if not items or not isinstance(item_index, int) or item_index < 0 or item_index >= len(items):
1859
1874
  return tl, None, _err(
1860
1875
  f"No item at index {item_index} on {track_type} track {track_index}",
1861
1876
  code="ITEM_INDEX_OUT_OF_RANGE", category="invalid_input",
@@ -5494,8 +5509,10 @@ def _bounded_basename_matches(
5494
5509
  p: Dict[str, Any],
5495
5510
  ) -> Tuple[List[str], Dict[str, Any]]:
5496
5511
  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)
5512
+ # Clamp to positive: a negative max_seconds/max_files would trip the
5513
+ # >= guards on the first iteration and silently return no results (EX8).
5514
+ max_seconds = max(0.1, float(p.get("max_seconds", p.get("timeout_seconds", 20)) or 20))
5515
+ max_files = max(1, int(p.get("max_files_scanned", p.get("maxFilesScanned", 50000)) or 50000))
5499
5516
  max_depth_raw = p.get("max_depth", p.get("maxDepth"))
5500
5517
  max_depth = int(max_depth_raw) if max_depth_raw is not None else None
5501
5518
  all_matches = bool(p.get("all_matches", False))
@@ -5704,7 +5721,7 @@ def _audio_item_from_params(tl, p: Dict[str, Any]):
5704
5721
  track_index = int(p.get("track_index", 1))
5705
5722
  item_index = int(p.get("item_index", 0))
5706
5723
  items = tl.GetItemListInTrack(track_type, track_index) or []
5707
- if item_index >= len(items):
5724
+ if item_index < 0 or item_index >= len(items): # reject negatives (EX5)
5708
5725
  return None, _err(f"No item at index {item_index} on {track_type} track {track_index}")
5709
5726
  return items[item_index], None
5710
5727
 
@@ -7474,6 +7491,23 @@ def _setup_positive_int(value: Any, default: int, min_value: int = 0, max_value:
7474
7491
  return max(min_value, min(parsed, max_value))
7475
7492
 
7476
7493
 
7494
+ def _safe_int(value: Any, default: int, *, minimum: Optional[int] = None, maximum: Optional[int] = None) -> int:
7495
+ """Coerce a tool param to int without throwing; clamp to [minimum, maximum].
7496
+
7497
+ Used by action handlers so a non-numeric or out-of-range numeric param yields
7498
+ a sane value instead of an unhandled ValueError or a SQLite "negative LIMIT =
7499
+ no limit" full-table fetch (EX8)."""
7500
+ try:
7501
+ parsed = int(value)
7502
+ except (TypeError, ValueError):
7503
+ parsed = default
7504
+ if minimum is not None:
7505
+ parsed = max(minimum, parsed)
7506
+ if maximum is not None:
7507
+ parsed = min(parsed, maximum)
7508
+ return parsed
7509
+
7510
+
7477
7511
  def _setup_marker_limit(value: Any, default: int = 12) -> int:
7478
7512
  if isinstance(value, str) and _setup_text_key(value) in {"unlimited", "no_limit", "nolimit", "all"}:
7479
7513
  return 0
@@ -13673,14 +13707,29 @@ class _SpecLiveExecutor:
13673
13707
 
13674
13708
 
13675
13709
  def _make_spec_hook_runner(timeout: float = 120.0):
13676
- """Return a callable that runs a Hook's shell command (opt-in only)."""
13710
+ """Return a callable that runs a Hook's command (opt-in only).
13711
+
13712
+ The command is split with shlex and run WITHOUT a shell, so a spec's hook
13713
+ string cannot inject arbitrary shell (`; rm -rf …`, pipes, expansion). A
13714
+ hook that needs shell features must invoke an explicit interpreter
13715
+ (e.g. ``bash -c "…"``) as its own argv, making the intent visible (EX1).
13716
+ """
13717
+ import shlex
13677
13718
  import subprocess
13678
13719
 
13679
13720
  def _run(hook) -> bool:
13721
+ try:
13722
+ argv = shlex.split(hook.command or "")
13723
+ except ValueError as exc:
13724
+ logger.warning("spec hook '%s' has an unparseable command: %s", hook.name or hook.command, exc)
13725
+ return False
13726
+ if not argv:
13727
+ logger.warning("spec hook '%s' has an empty command", hook.name or "")
13728
+ return False
13680
13729
  try:
13681
13730
  proc = subprocess.run(
13682
- hook.command,
13683
- shell=True,
13731
+ argv,
13732
+ shell=False,
13684
13733
  timeout=timeout,
13685
13734
  stdin=subprocess.DEVNULL,
13686
13735
  capture_output=True,
@@ -14891,7 +14940,20 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
14891
14940
  for sub in (root.GetSubFolderList() or []):
14892
14941
  if sub.GetUniqueId() == fid:
14893
14942
  folders.append(sub)
14894
- return {"success": bool(mp.DeleteFolders(folders))} if folders else _err("No folders found")
14943
+ if not folders:
14944
+ return _err("No folders found")
14945
+ if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required():
14946
+ return _issue_confirm_token(
14947
+ action="media_pool.delete_folders", params=p,
14948
+ preview={"operation": "media_pool.delete_folders",
14949
+ "warning": "Permanently deletes folders AND every clip they contain.",
14950
+ "folders_lost": len(folders),
14951
+ "names": [_clip_name(f) for f in folders][:25]},
14952
+ )
14953
+ blocked = _consume_confirm_token(action="media_pool.delete_folders", params=p)
14954
+ if blocked:
14955
+ return blocked
14956
+ return {"success": bool(mp.DeleteFolders(folders))}
14895
14957
  elif action == "move_folders":
14896
14958
  target = _navigate_folder(mp, p["target_path"])
14897
14959
  if not target:
@@ -14984,7 +15046,20 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
14984
15046
  tl = proj.GetTimelineByIndex(i)
14985
15047
  if tl and tl.GetUniqueId() in p["timeline_ids"]:
14986
15048
  timelines.append(tl)
14987
- return {"success": bool(mp.DeleteTimelines(timelines))} if timelines else _err("No timelines found")
15049
+ if not timelines:
15050
+ return _err("No timelines found")
15051
+ if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required():
15052
+ return _issue_confirm_token(
15053
+ action="media_pool.delete_timelines", params=p,
15054
+ preview={"operation": "media_pool.delete_timelines",
15055
+ "warning": "Permanently deletes timelines.",
15056
+ "timelines_lost": len(timelines),
15057
+ "names": [t.GetName() for t in timelines][:25]},
15058
+ )
15059
+ blocked = _consume_confirm_token(action="media_pool.delete_timelines", params=p)
15060
+ if blocked:
15061
+ return blocked
15062
+ return {"success": bool(mp.DeleteTimelines(timelines))}
14988
15063
  elif action == "append_to_timeline":
14989
15064
  if p.get("clip_infos") is not None:
14990
15065
  raw = p["clip_infos"]
@@ -15071,7 +15146,20 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
15071
15146
  elif action == "delete_clips":
15072
15147
  clips = [_find_clip(root, cid) for cid in p["clip_ids"]]
15073
15148
  clips = [c for c in clips if c]
15074
- return {"success": bool(mp.DeleteClips(clips))} if clips else _err("No clips found")
15149
+ if not clips:
15150
+ return _err("No clips found")
15151
+ if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required():
15152
+ return _issue_confirm_token(
15153
+ action="media_pool.delete_clips", params=p,
15154
+ preview={"operation": "media_pool.delete_clips",
15155
+ "warning": "Permanently deletes Media Pool clips.",
15156
+ "clips_lost": len(clips),
15157
+ "names": [_clip_name(c) for c in clips][:25]},
15158
+ )
15159
+ blocked = _consume_confirm_token(action="media_pool.delete_clips", params=p)
15160
+ if blocked:
15161
+ return blocked
15162
+ return {"success": bool(mp.DeleteClips(clips))}
15075
15163
  elif action == "move_clips":
15076
15164
  target = _navigate_folder(mp, p["target_path"])
15077
15165
  if not target:
@@ -15965,7 +16053,7 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
15965
16053
  _r, _proj, project_root, _name = vctx
15966
16054
  session_only = bool(p.get("session_only", False))
15967
16055
  session_id = _AI_LEDGER_SESSION_ID if session_only else None
15968
- limit = int(p.get("limit", 50))
16056
+ limit = _safe_int(p.get("limit"), 50, minimum=1, maximum=1000)
15969
16057
  return {
15970
16058
  "success": True,
15971
16059
  "session_id": _AI_LEDGER_SESSION_ID,
@@ -16821,7 +16909,7 @@ def timeline_versioning(action: str, params: Optional[Dict[str, Any]] = None) ->
16821
16909
  if action == "list_runs":
16822
16910
  return {
16823
16911
  "success": True,
16824
- "runs": _analysis_runs.list_runs(project_root, limit=int(p.get("limit", 50))),
16912
+ "runs": _analysis_runs.list_runs(project_root, limit=_safe_int(p.get("limit"), 50, minimum=1, maximum=1000)),
16825
16913
  }
16826
16914
  if action == "archive_current":
16827
16915
  return _timeline_versioning.archive_current_timeline(
@@ -16842,13 +16930,18 @@ def timeline_versioning(action: str, params: Optional[Dict[str, Any]] = None) ->
16842
16930
  if action == "diff_versions":
16843
16931
  if not p.get("timeline_name") or "from_version" not in p or "to_version" not in p:
16844
16932
  return _err("timeline_name, from_version, to_version required")
16933
+ try:
16934
+ from_version = int(p["from_version"])
16935
+ to_version = int(p["to_version"])
16936
+ except (TypeError, ValueError):
16937
+ return _err("from_version and to_version must be integers")
16845
16938
  return {
16846
16939
  "success": True,
16847
16940
  **_timeline_versioning.diff_versions(
16848
16941
  project_root=project_root,
16849
16942
  timeline_name=str(p["timeline_name"]),
16850
- from_version=int(p["from_version"]),
16851
- to_version=int(p["to_version"]),
16943
+ from_version=from_version,
16944
+ to_version=to_version,
16852
16945
  ),
16853
16946
  }
16854
16947
  if action == "diff_timelines":
@@ -16864,18 +16957,24 @@ def timeline_versioning(action: str, params: Optional[Dict[str, Any]] = None) ->
16864
16957
  project_root=project_root,
16865
16958
  timeline_name=p.get("timeline_name"),
16866
16959
  analysis_run_id=p.get("analysis_run_id"),
16867
- limit=int(p.get("limit", 50)),
16960
+ limit=_safe_int(p.get("limit"), 50, minimum=1, maximum=1000),
16868
16961
  )
16869
16962
  return {"success": True, "edits": rows}
16870
16963
  if action == "rollback":
16871
16964
  if not p.get("timeline_name") or "version" not in p:
16872
16965
  return _err("timeline_name and version required")
16966
+ try:
16967
+ version = int(p["version"])
16968
+ except (TypeError, ValueError):
16969
+ return _err("version must be an integer")
16970
+ if version < 0:
16971
+ return _err("version must be >= 0")
16873
16972
  return _timeline_versioning.rollback_to_version(
16874
16973
  resolve=resolve_h,
16875
16974
  project=project_h,
16876
16975
  project_root=project_root,
16877
16976
  timeline_name=str(p["timeline_name"]),
16878
- version=int(p["version"]),
16977
+ version=version,
16879
16978
  analysis_run_id=p.get("analysis_run_id"),
16880
16979
  )
16881
16980
  if action == "prune":
@@ -16886,7 +16985,7 @@ def timeline_versioning(action: str, params: Optional[Dict[str, Any]] = None) ->
16886
16985
  project=project_h,
16887
16986
  project_root=project_root,
16888
16987
  timeline_name=str(p["timeline_name"]),
16889
- keep_n=int(p.get("keep_n", 10)),
16988
+ keep_n=_safe_int(p.get("keep_n"), 10, minimum=1, maximum=1000),
16890
16989
  )
16891
16990
  if action == "registry":
16892
16991
  return {"success": True, **_brain_edits.read_brain_edits_registry(project_root)}
@@ -16897,7 +16996,7 @@ def timeline_versioning(action: str, params: Optional[Dict[str, Any]] = None) ->
16897
16996
  project_root=project_root,
16898
16997
  analysis_run_id=p.get("analysis_run_id"),
16899
16998
  action=p.get("media_pool_action"),
16900
- limit=int(p.get("limit", 50)),
16999
+ limit=_safe_int(p.get("limit"), 50, minimum=1, maximum=1000),
16901
17000
  ),
16902
17001
  }
16903
17002
  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] = []
@@ -41,18 +41,17 @@ logger = logging.getLogger("resolve-mcp.destructive-hook")
41
41
  # default metric.
42
42
 
43
43
  DESTRUCTIVE_ACTIONS_BY_TOOL: Dict[str, FrozenSet[str]] = {
44
+ # Real COMPOUND media_pool action strings (the @destructive_op("media_pool")
45
+ # wrapper queries is_destructive("media_pool", <compound action>)). The prior
46
+ # entries used granular function names (delete_media_pool_clips, …) and
47
+ # media_pool_item names that the compound tool never dispatches, so catastrophic
48
+ # deletes silently bypassed version-on-mutate archiving (EX2). media_pool_item
49
+ # link/replace actions are governed separately (its own tool is not yet wrapped).
44
50
  "media_pool": frozenset({
45
- "delete_media_pool_clips",
46
- "delete_media_pool_folders",
51
+ "delete_clips",
52
+ "delete_folders",
47
53
  "move_clips",
48
- "move_media_pool_folders",
49
- "replace_clip",
50
- "replace_clip_preserve_sub_clip",
51
- "link_clip_proxy_media",
52
- "unlink_clip_proxy_media",
53
- "link_proxy_media",
54
- "unlink_proxy_media",
55
- "link_clip_full_resolution_media",
54
+ "move_folders",
56
55
  "delete_clip_mattes",
57
56
  "delete_timelines",
58
57
  }),
@@ -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: