davinci-resolve-mcp 3.0.0 → 3.0.1

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,100 @@
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 v3.0.1 — project deletion is gated, and the open project is refused by default
6
+
7
+ A security fix, published as [GHSA-gmp7-qjp9-m7gm](https://github.com/samuelgursky/davinci-resolve-mcp/security/advisories/GHSA-gmp7-qjp9-m7gm).
8
+ One behaviour change existing callers may notice is called out below.
9
+
10
+ ### Security
11
+
12
+ - **`project_manager delete` permanently deleted any named project — including
13
+ the one open in Resolve — without confirmation, and no gate saw it.** It runs
14
+ through `delete_project_safely`, a *reliability* helper that works around
15
+ DeleteProject's flakiness and session lock by closing the open project and
16
+ then deleting it. The `project_manager` tool carried no `@_destructive_op`,
17
+ `delete` was not registered, and the CRITICAL risk rule written for project
18
+ deletion named `delete_project`, an action no tool dispatches. So it matched
19
+ nothing: safe mode, dry-run refusal and the security audit log never saw a
20
+ project deletion.
21
+ - **Thirteen further deletes that the classifier already rated HIGH were not
22
+ enforced, for the same reason.** They are the three `render` deletes,
23
+ `render_presets.delete_burnin`, `gallery_stills.delete_stills`, `fusion_comp`
24
+ `delete_tool` and `delete_keyframe`, `timeline_item.delete_keyframe`, the three
25
+ `media_pool_item_markers` deletes, `project_settings.delete_color_group` and
26
+ `resolve_control.delete_user_preferences_preset`. Safe mode is enforced only
27
+ by the decorator, and only for registered actions, so these ratings were a
28
+ promise nothing kept.
29
+
30
+ ### Changed
31
+
32
+ - **The raw `delete` now refuses the currently open project unless
33
+ `close_current=True`**, matching `safe_project_delete`. This is the one change
34
+ existing callers may notice: a call that used to close and delete the open
35
+ project now returns an error, until it passes `close_current=True`.
36
+ - All of the actions above are registered and their tools decorated. The project
37
+ delete is CRITICAL, and `safe_project_delete` and the thirteen others are HIGH,
38
+ so all are blocked while safe mode is on. An explicit dry run is refused
39
+ unless the action honours it natively; only `safe_project_delete` does, and
40
+ keeps working. Every call is audited. None of them archives the timeline,
41
+ except the keyframe deletes on `fusion_comp` and `timeline_item`, which change
42
+ timeline items and so still do.
43
+ - **`remove_motion_blur`** (on `folder` and `media_pool_item`) is **re-rated
44
+ MEDIUM**. It renders new media and never touches the source, and was already
45
+ confirm-gated for exactly that reason, but the `remove_` name-prefix rule had
46
+ rated it HIGH on its name alone. It is now audited, and not blocked by safe
47
+ mode.
48
+ - **The name-prefix rule is now a fallback for unlisted actions**: an explicit
49
+ lower rating wins. Before, it fired ahead of the LOW and MEDIUM tables and
50
+ could not be overridden. No existing rating changed except `remove_motion_blur`
51
+ — no LOW, MEDIUM or graph-LUT entry started with `delete_` or `remove_`.
52
+
53
+ ### Fixed
54
+
55
+ - **Nine risk rules named actions that no tool dispatches, and so protected
56
+ nothing.** On `project_manager`: `delete_project`, now repointed at the real
57
+ `delete`, plus `close_project_without_saving` and `save_project_as`. On
58
+ `edit_engine`: `auto_cut_silence` and `ripple_trim`. On `timeline`: `cut_clip`,
59
+ `delete_clip_by_id`, `delete_markers` and `ripple_delete` — which is a CutList
60
+ entry kind inside `apply_cuts`, not an action. The rest were removed; since
61
+ they never matched, removing them changes nothing at runtime. An existing test
62
+ even asserted that the dead `delete_project` rule classified as CRITICAL — true
63
+ of a name no tool uses, and part of how it survived. It now tests the real
64
+ `delete`.
65
+ - **The bridge installer's Lua canary gave only the pre-21.1 diagnosis** — that
66
+ Resolve cannot find a Python 3. On free 21.1 that is wrong, because Python
67
+ scripting moved to Studio (#203). Its comments and printed output now give both
68
+ causes, the newer first (#219). The printed post-install guidance was already
69
+ corrected in v2.224.1.
70
+
71
+ ### Added
72
+
73
+ - **`tests/test_write_enforcement_ratchet.py`** fails the suite in three cases:
74
+ - an action is rated destructive but not enforced;
75
+ - a risk rule names an action no tool dispatches;
76
+ - a new write-style action appears with neither a rating nor a registry entry.
77
+
78
+ The 144 unrated write-style actions that exist today are frozen as a backlog.
79
+ Rating one forces its removal from the list, so it can only shrink. This is
80
+ the second instance of this gap in two days, and the first time it cannot
81
+ come back unnoticed.
82
+ - `tests/test_project_delete_guard.py` pins the delete guard, its rating and its
83
+ enforcement.
84
+
85
+ ### Documentation
86
+
87
+ - Removed a stale tool count from `docs/authoring/script-plugin-authoring.md`.
88
+
89
+ ### Validation
90
+
91
+ - Full suite green: 3,482 passed, 1 skipped. Every static and drift gate is clean, including
92
+ the native-dry-run scan. That scan requires the native-dry-run list to match,
93
+ exactly, the registered actions whose handlers read `dry_run`, which is how
94
+ `safe_project_delete` was confirmed as the only one.
95
+ - No live Resolve run. The gating is decorator-level and verified offline. The
96
+ delete guard is tested against a fake project manager, deliberately:
97
+ exercising it live means deleting a real project.
98
+
5
99
  ## What's New in v3.0.0 — the server no longer executes caller-supplied code, and every plugin write is gated
6
100
 
7
101
  **A breaking release.** Two public actions are removed. The rest of the change
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  English | [简体中文](README.zh-CN.md)
4
4
 
5
- [![Version](https://img.shields.io/badge/version-3.0.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-3.0.1-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-36%20(377%20full)-blue.svg)](#server-modes)
package/README.zh-CN.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [English](README.md) | 简体中文
4
4
 
5
- [![Version](https://img.shields.io/badge/version-3.0.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-3.0.1-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-36%20(377%20full)-blue.svg)](#服务器模式)
@@ -12,7 +12,7 @@
12
12
  [![Python](https://img.shields.io/badge/python-3.10+-green.svg)](https://www.python.org/downloads/)
13
13
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT)
14
14
 
15
- > 本翻译对应 v3.0.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v3.0.1 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
@@ -19,7 +19,7 @@ automation.
19
19
  | Custom workflow you want as a permanent menu item | `script_plugin('install', ...)`, then the user runs it from Workspace → Scripts |
20
20
  | Image-processing node for the Fusion page | `fuse_plugin` |
21
21
  | Color-page programmable transform | `dctl` |
22
- | Anything the existing 28 wrapped Resolve API tools already cover | The wrapped tool — no scripting needed |
22
+ | Anything the wrapped Resolve API tools already cover | The wrapped tool — no scripting needed |
23
23
 
24
24
  ## Two template kinds
25
25
 
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "3.0.0"
40
+ VERSION = "3.0.1"
41
41
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
42
42
  # Resolve's scripting bridge loads into newer interpreters on recent builds
43
43
  # (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": "3.0.0",
3
+ "version": "3.0.1",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -288,7 +288,9 @@ _CANARY_NAME = "resolve_bridge_canary.lua"
288
288
 
289
289
  _LUA_CANARY = """-- Installed by davinci-resolve-mcp as an enumeration canary.
290
290
  -- If THIS appears under Workspace > Scripts but resolve_bridge_probe does not,
291
- -- Resolve is listing Lua and silently skipping Python: it cannot find a Python 3.
291
+ -- Resolve is listing Lua and silently skipping Python. On Resolve 21.1+ FREE
292
+ -- that is expected: Python scripting moved to Studio (issue #203), and nothing
293
+ -- below will change it. On Studio, or 21.0.x and earlier, it cannot find a Python 3.
292
294
  -- It looks at PYTHON3HOME, then /usr/local/bin/python3 -- and nowhere else, which
293
295
  -- is why Homebrew, pyenv, uv and conda interpreters go unseen. Either point it at
294
296
  -- the one you have (no sudo, but does NOT survive a reboot):
@@ -299,9 +301,12 @@ _LUA_CANARY = """-- Installed by davinci-resolve-mcp as an enumeration canary.
299
301
  -- already looks, which persists:
300
302
  -- sudo ln -s "$(command -v python3)" /usr/local/bin/python3
301
303
  -- A python.org build creates that symlink for you. Restart Resolve after.
302
- print("Resolve is enumerating scripts. If the Python probe is missing, Resolve")
303
- print("cannot find a Python 3: set PYTHON3HOME with launchctl setenv, or install")
304
- print("a python.org build. Homebrew/pyenv/uv/conda are not looked at directly.")
304
+ print("Resolve is enumerating scripts. If the Python probe is missing:")
305
+ print("- Resolve 21.1+ FREE: Python scripting moved to Studio, so .py scripts")
306
+ print(" no longer list at all (issue #203). No Python setting changes that.")
307
+ print("- Studio, or 21.0.x and earlier: Resolve cannot find a Python 3. Set")
308
+ print(" PYTHON3HOME with launchctl setenv, or install a python.org build.")
309
+ print(" Homebrew/pyenv/uv/conda are not looked at directly.")
305
310
  """
306
311
 
307
312
 
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "3.0.0"
90
+ VERSION = "3.0.1"
91
91
  logger = logging.getLogger("davinci-resolve-mcp")
92
92
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
93
93
  logger.info(f"Detected platform: {get_platform()}")
package/src/server.py CHANGED
@@ -11,7 +11,7 @@ Usage:
11
11
  python src/server.py --full # Start the 377-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "3.0.0"
14
+ VERSION = "3.0.1"
15
15
 
16
16
  import base64
17
17
  import os
@@ -16575,6 +16575,7 @@ def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
16575
16575
 
16576
16576
  @mcp.tool()
16577
16577
  @_guard_missing_params
16578
+ @_destructive_op("resolve_control")
16578
16579
  def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
16579
16580
  """App-level DaVinci Resolve operations.
16580
16581
 
@@ -18072,6 +18073,7 @@ def layout_presets(action: str, params: Optional[Dict[str, Any]] = None) -> Dict
18072
18073
 
18073
18074
  @mcp.tool()
18074
18075
  @_guard_missing_params
18076
+ @_destructive_op("render_presets")
18075
18077
  def render_presets(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
18076
18078
  """Import/export render and burn-in presets.
18077
18079
 
@@ -19030,6 +19032,7 @@ def _project_lint_live(r, pm) -> Dict[str, Any]:
19030
19032
 
19031
19033
  @mcp.tool()
19032
19034
  @_guard_missing_params
19035
+ @_destructive_op("project_manager")
19033
19036
  def project_manager(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
19034
19037
  """Manage DaVinci Resolve projects.
19035
19038
 
@@ -19047,7 +19050,7 @@ def project_manager(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
19047
19050
  render and wedges Resolve's pipeline until restart (stuck
19048
19051
  IsRenderingInProgress, 0% jobs, refused Quit). stop_render=true stops
19049
19052
  the render, waits for the flag to clear, then closes.
19050
- delete(name) -> {success}
19053
+ delete(name, close_current?) -> {success}
19051
19054
  import_project(path, name?) -> {success}
19052
19055
  export_project(name, path, with_stills_and_luts?) -> {success}
19053
19056
  archive(name, path, src_media?, render_cache?, proxy_media?) -> {success}
@@ -19180,6 +19183,18 @@ def project_manager(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
19180
19183
  elif action == "delete":
19181
19184
  if not p.get("name"):
19182
19185
  return _err("delete requires name")
19186
+ # Refuse the open project unless the caller says so, as
19187
+ # safe_project_delete does. delete_project_safely closes and deletes the
19188
+ # current project without asking — the call that loses a project someone
19189
+ # is working in. If the current project cannot be read, DeleteProject on
19190
+ # an open project fails anyway (the session holds its lock).
19191
+ try:
19192
+ _open = pm.GetCurrentProject()
19193
+ _open_name = _open.GetName() if _open else None
19194
+ except Exception:
19195
+ _open_name = None
19196
+ if _open_name == p["name"] and not p.get("close_current", False):
19197
+ return _err("Refusing to delete the currently open project; pass close_current=True")
19183
19198
  from src.utils.project_cleanup import delete_project_safely
19184
19199
  deleted = delete_project_safely(pm, p["name"])
19185
19200
  return {"success": bool(deleted.get("success")), "delete_detail": deleted}
@@ -19364,6 +19379,7 @@ def _setting_limitation(name: Any, obj: str = "Project") -> Optional[Dict[str, A
19364
19379
 
19365
19380
  @mcp.tool()
19366
19381
  @_guard_missing_params
19382
+ @_destructive_op("project_settings")
19367
19383
  def project_settings(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
19368
19384
  """Project metadata, settings, and color groups.
19369
19385
 
@@ -20340,6 +20356,7 @@ def _export_render_boundary_report(proj, p: Dict[str, Any]):
20340
20356
 
20341
20357
  @mcp.tool()
20342
20358
  @_guard_missing_params
20359
+ @_destructive_op("render")
20343
20360
  def render(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
20344
20361
  """Render pipeline: jobs, presets, formats, codecs, and rendering.
20345
20362
 
@@ -21319,6 +21336,7 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
21319
21336
 
21320
21337
  @mcp.tool()
21321
21338
  @_guard_missing_params
21339
+ @_destructive_op("folder")
21322
21340
  def folder(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
21323
21341
  """Operations on Media Pool folders.
21324
21342
 
@@ -21491,6 +21509,7 @@ def _keyed_get(getter, key):
21491
21509
 
21492
21510
  @mcp.tool()
21493
21511
  @_guard_missing_params
21512
+ @_destructive_op("media_pool_item")
21494
21513
  def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
21495
21514
  """Operations on a media pool clip. Identify clip by clip_id.
21496
21515
 
@@ -21955,6 +21974,7 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
21955
21974
 
21956
21975
  @mcp.tool()
21957
21976
  @_guard_missing_params
21977
+ @_destructive_op("media_pool_item_markers")
21958
21978
  def media_pool_item_markers(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
21959
21979
  """Markers and flags on media pool clips. Identify clip by clip_id.
21960
21980
 
@@ -28805,6 +28825,7 @@ def gallery(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, A
28805
28825
 
28806
28826
  @mcp.tool()
28807
28827
  @_guard_missing_params
28828
+ @_destructive_op("gallery_stills")
28808
28829
  def gallery_stills(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
28809
28830
  """Manage stills in gallery albums (best results on Color page).
28810
28831
 
@@ -30261,6 +30282,7 @@ def _fusion_get_text_plus(comp, p: Dict[str, Any]) -> Dict[str, Any]:
30261
30282
 
30262
30283
  @mcp.tool()
30263
30284
  @_guard_missing_params
30285
+ @_destructive_op("fusion_comp")
30264
30286
  def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
30265
30287
  """Fusion composition node graph operations.
30266
30288
 
@@ -77,6 +77,24 @@ DESTRUCTIVE_ACTIONS_BY_TOOL: Dict[str, FrozenSet[str]] = {
77
77
  "safe_install_extension",
78
78
  "safe_remove_extension",
79
79
  }),
80
+ # Deletes the classifier already rated HIGH (CRITICAL for the raw project
81
+ # delete) on tools that carried no @_destructive_op, so safe mode never saw
82
+ # them. Plus the 21.0 AI deblur, rated MEDIUM: it creates media, and is
83
+ # registered so it is audited and its dry run is honest.
84
+ "folder": frozenset({"remove_motion_blur"}),
85
+ "fusion_comp": frozenset({"delete_keyframe", "delete_tool"}),
86
+ "gallery_stills": frozenset({"delete_stills"}),
87
+ "media_pool_item": frozenset({"remove_motion_blur"}),
88
+ "media_pool_item_markers": frozenset({
89
+ "delete_at_frame",
90
+ "delete_by_color",
91
+ "delete_by_custom_data",
92
+ }),
93
+ "project_manager": frozenset({"delete", "safe_project_delete"}),
94
+ "project_settings": frozenset({"delete_color_group"}),
95
+ "render": frozenset({"delete_all_jobs", "delete_job", "delete_preset"}),
96
+ "render_presets": frozenset({"delete_burnin"}),
97
+ "resolve_control": frozenset({"delete_user_preferences_preset"}),
80
98
  "media_pool": frozenset({
81
99
  "delete_clips",
82
100
  "delete_folders",
@@ -155,6 +173,7 @@ DESTRUCTIVE_ACTIONS_BY_TOOL: Dict[str, FrozenSet[str]] = {
155
173
  "create_subtitles",
156
174
  }),
157
175
  "timeline_item": frozenset({
176
+ "delete_keyframe",
158
177
  "set_output_blanking",
159
178
  "set_use_timeline_for_output_blanking",
160
179
  "set_clip_enabled",
@@ -250,7 +269,9 @@ NO_ARCHIVE_ON_KEYS: Dict[Tuple[str, str], frozenset] = {
250
269
 
251
270
  # ── Non-timeline write tools ────────────────────────────────────────────────
252
271
  #
253
- # Tools whose registered actions write the filesystem, not the timeline. Every
272
+ # Tools whose registered actions do not mutate the working timeline: they write
273
+ # plugin folders, or act on projects, the render queue, presets, the gallery,
274
+ # pool items or app preferences. Every
254
275
  # gate applies to them — safe mode, dry-run refusal, the audit log — but they
255
276
  # skip version-on-mutate archiving, and skip resolving the versioning context
256
277
  # at all: that goes through the project-root provider, which reaches Resolve,
@@ -258,7 +279,12 @@ NO_ARCHIVE_ON_KEYS: Dict[Tuple[str, str], frozenset] = {
258
279
  # Resolve. `media_pool` has its own branch for the same reason; these differ in
259
280
  # that there is no project state to log.
260
281
 
261
- NON_TIMELINE_WRITE_TOOLS: frozenset = frozenset({"dctl", "fuse_plugin", "script_plugin"})
282
+ NON_TIMELINE_WRITE_TOOLS: frozenset = frozenset({
283
+ "dctl", "fuse_plugin", "script_plugin",
284
+ "folder", "gallery_stills", "media_pool_item", "media_pool_item_markers",
285
+ "project_manager", "project_settings", "render", "render_presets",
286
+ "resolve_control",
287
+ })
262
288
 
263
289
 
264
290
  # ── Strict-mode allowlist ───────────────────────────────────────────────────
@@ -327,6 +353,7 @@ NATIVE_DRY_RUN_ACTIONS: frozenset = frozenset({
327
353
  ("timeline_ai", "create_subtitles"),
328
354
  ("script_plugin", "safe_install_extension"),
329
355
  ("script_plugin", "safe_remove_extension"),
356
+ ("project_manager", "safe_project_delete"),
330
357
  })
331
358
 
332
359
 
@@ -125,8 +125,10 @@ class RiskClassificationHook(LifecycleHook):
125
125
  name = "risk_classification"
126
126
 
127
127
  _CRITICAL_ACTIONS: Set[Tuple[str, str]] = {
128
- ("project_manager", "delete_project"),
129
- ("project_manager", "close_project_without_saving"),
128
+ # Raw project delete. This rule used to name `delete_project`, which
129
+ # no tool dispatches, so it matched nothing and deleting a project
130
+ # passed every gate.
131
+ ("project_manager", "delete"),
130
132
  ("media_pool", "delete_timelines"),
131
133
  ("media_pool", "delete_clips"),
132
134
  }
@@ -140,15 +142,11 @@ class RiskClassificationHook(LifecycleHook):
140
142
  ("fuse_plugin", "remove"),
141
143
  ("script_plugin", "remove"),
142
144
  ("script_plugin", "safe_remove_extension"),
145
+ # Guarded delete: disposable `_mcp_` projects only, and the open one
146
+ # only with close_current=True — but still permanent.
147
+ ("project_manager", "safe_project_delete"),
143
148
  ("timeline", "delete_clips"),
144
- ("timeline", "delete_clip_by_id"),
145
- ("timeline", "delete_markers"),
146
- ("timeline", "ripple_delete"),
147
- ("timeline", "cut_clip"),
148
149
  ("edit_engine", "execute_selects"),
149
- ("edit_engine", "auto_cut_silence"),
150
- ("edit_engine", "ripple_trim"),
151
- ("project_manager", "save_project_as"),
152
150
  ("media_pool", "delete_folders"),
153
151
  ("timeline", "delete_track"),
154
152
  ("timeline", "lift_range"),
@@ -264,6 +262,11 @@ class RiskClassificationHook(LifecycleHook):
264
262
  ("fuse_plugin", "install"),
265
263
  ("script_plugin", "install"),
266
264
  ("script_plugin", "safe_install_extension"),
265
+ # 21.0 AI deblur renders NEW media and never touches the source (it is
266
+ # confirm-token gated for that reason). The `remove_` prefix rule rated
267
+ # it HIGH on its name alone, which would make safe mode block a create.
268
+ ("folder", "remove_motion_blur"),
269
+ ("media_pool_item", "remove_motion_blur"),
267
270
  # Additive edits that place content into an existing timeline. Nothing
268
271
  # is deleted (`overwrite_range`, which does delete, is HIGH), but the
269
272
  # timeline is no longer what it was.
@@ -378,7 +381,16 @@ class RiskClassificationHook(LifecycleHook):
378
381
  radius = BlastRadius.PROJECT if "project" in tool_name else BlastRadius.TIMELINE
379
382
  conf_required = True
380
383
  reasons.append(f"Action '{action}' is permanently destructive across {radius.value}")
381
- elif pair in cls._HIGH_RISK_ACTIONS or action.startswith("delete_") or action.startswith("remove_"):
384
+ elif pair in cls._HIGH_RISK_ACTIONS or (
385
+ # The name-prefix rule is a fallback for UNLISTED actions: an
386
+ # explicit lower rating wins. It used to fire first and so could
387
+ # not be overridden — `remove_motion_blur` creates media, yet read
388
+ # HIGH on its name.
389
+ pair not in cls._LOW_RISK_ACTIONS
390
+ and pair not in cls._MEDIUM_RISK_ACTIONS
391
+ and pair not in cls._GRAPH_LUT_ACTIONS
392
+ and (action.startswith("delete_") or action.startswith("remove_"))
393
+ ):
382
394
  level = RiskLevel.HIGH
383
395
  destructive = True
384
396
  if params.get("ripple", False):