davinci-resolve-mcp 2.208.1 → 2.209.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,70 @@
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.209.1 — the test suite no longer writes to the security audit log
6
+
7
+ ### Fixed
8
+
9
+ - **Running the suite appended fabricated events to `logs/security-audit.jsonl`.**
10
+ The destructive-op audit log added in v2.209.0 defaults to that path, which is
11
+ correct for an install and wrong for a test run: any test exercising a
12
+ `@destructive_op`-wrapped handler wrote a genuine-looking record.
13
+ `tests/test_tool_argument_validation` walks every tool, so a single run added
14
+ 24 synthetic `delete_timelines` / `reset_all_grades` / `apply_cuts` entries,
15
+ and repeated runs accumulated 216.
16
+
17
+ A security log is read to establish what actually happened, so synthetic rows
18
+ in it are worse than a missing feature — at the point someone needs to trust
19
+ the file they are indistinguishable from real events. `tests/offline_guard`
20
+ now redirects the audit path to a temp file for the whole run, covering both
21
+ the pytest and `python -m unittest` entry points, and only replaces the
22
+ *default*: a test that configures `destructive.audit_log_path` still gets its
23
+ own path. A regression test asserts the active path is never inside the repo.
24
+
25
+ No released behaviour changes — the default remains `logs/security-audit.jsonl`
26
+ for real installs. Anyone who ran the v2.209.0 suite should expect synthetic
27
+ rows in their local file; they carry temp-directory `project_root` values.
28
+
29
+ ## What's New in v2.209.0 — safe operations policy
30
+
31
+ ### Added
32
+
33
+ - **Destructive operations now carry explicit security metadata** — wrapped
34
+ destructive tool calls receive an `operation_id` plus a `security` block with
35
+ a `risk_level` (`low`, `medium`, `high`, or `critical`) and a
36
+ `risk_established` flag. The existing version-on-mutate and confirm-token
37
+ gates stay intact, but callers now have a stable policy surface to inspect
38
+ and display before or after a Resolve mutation.
39
+
40
+ Levels come from the same classifier that backs pre-flight
41
+ `inspect_operation`, so the gate and the inspection surface cannot disagree
42
+ about a call. `risk_established` is false when the classifier matched no rule
43
+ and the level is a name-based default rather than a finding — true today for
44
+ 80 of the 108 registered destructive actions, which is a gap to close by
45
+ classifying them, not by gating them.
46
+ - **Safe mode blocks high-risk destructive calls when enabled** —
47
+ `setup(action="set_defaults", params={"destructive": {"safe_mode": true}})`
48
+ blocks `high` and `critical` actions before the underlying Resolve handler
49
+ runs. Reviewed one-off calls can proceed with `allow_risky_operation=true`.
50
+ Unclassified actions are reported, not blocked.
51
+ - **Security audit JSONL for destructive calls** — allowed, blocked, and
52
+ pending-confirmation destructive calls write audit events to
53
+ `logs/security-audit.jsonl` by default. Confirmation tokens are redacted in
54
+ the audit payload.
55
+
56
+ ### Changed
57
+
58
+ - **Risk classification is now one table, not two** — the marker and
59
+ clip-colour actions classify as `low` instead of falling through the name
60
+ heuristic as unrecognised `medium`, and `timeline.lift_range`,
61
+ `timeline.overwrite_range`, `timeline.apply_cuts`, `timeline.delete_track`,
62
+ `media_pool.delete_folders` and `graph.reset_all_grades` are now classified
63
+ `high` wherever risk is reported, including pre-flight inspection.
64
+ - **`setup` exposes destructive defaults** — `destructive.require_confirm_token`,
65
+ `destructive.safe_mode`, `destructive.audit_log`, and
66
+ `destructive.audit_log_path` are now visible through `schema`, persisted by
67
+ `set_defaults`, and reset by `clear_defaults`.
68
+
5
69
  ## What's New in v2.208.1 — #188: variant item counts come from the timeline
6
70
 
7
71
  ### Fixed
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-2.208.1-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.209.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(353%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-2.208.1-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.209.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(353%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
- > 本翻译对应 v2.208.1 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v2.209.1 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "2.208.1"
40
+ VERSION = "2.209.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": "2.208.1",
3
+ "version": "2.209.1",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "2.208.1"
90
+ VERSION = "2.209.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 353-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.208.1"
14
+ VERSION = "2.209.1"
15
15
 
16
16
  import base64
17
17
  import os
@@ -1248,7 +1248,12 @@ def _ai_governance_gate(op: str, p: Dict[str, Any]) -> Optional[Dict[str, Any]]:
1248
1248
  def _destructive_preference_provider(key: str) -> Any:
1249
1249
  """Reader for C6 preferences out of the existing media-analysis prefs file."""
1250
1250
  try:
1251
- return _read_media_analysis_preferences().get(key)
1251
+ prefs = _read_media_analysis_preferences()
1252
+ if key.startswith("destructive."):
1253
+ destructive = prefs.get("destructive")
1254
+ if isinstance(destructive, dict):
1255
+ return destructive.get(key.split(".", 1)[1])
1256
+ return prefs.get(key)
1252
1257
  except Exception:
1253
1258
  return None
1254
1259
 
@@ -15425,6 +15430,101 @@ def _setup_defaults_snapshot() -> Dict[str, Any]:
15425
15430
  "general": _setup_general_defaults(),
15426
15431
  "media_analysis": _setup_media_analysis_defaults(),
15427
15432
  "updates": _setup_updates_defaults(),
15433
+ "destructive": _setup_destructive_defaults(),
15434
+ }
15435
+
15436
+
15437
+ def _setup_destructive_defaults() -> Dict[str, Any]:
15438
+ prefs = _read_media_analysis_preferences()
15439
+ destructive = prefs.get("destructive") if isinstance(prefs.get("destructive"), dict) else {}
15440
+ return {
15441
+ "require_confirm_token": _setup_bool(destructive.get("require_confirm_token"), True),
15442
+ "safe_mode": _setup_bool(destructive.get("safe_mode"), False),
15443
+ "audit_log": _setup_bool(destructive.get("audit_log"), True),
15444
+ "audit_log_path": destructive.get("audit_log_path") or os.path.join(project_dir, "logs", "security-audit.jsonl"),
15445
+ "preferences_path": _media_analysis_preferences_path(),
15446
+ }
15447
+
15448
+
15449
+ def _setup_set_destructive_defaults(destructive_defaults: Dict[str, Any], dry_run: bool) -> Dict[str, Any]:
15450
+ if not destructive_defaults:
15451
+ return {"changed": False, "recognized": False}
15452
+
15453
+ alias_to_key = {
15454
+ "require_confirm_token": "require_confirm_token",
15455
+ "requireconfirmtoken": "require_confirm_token",
15456
+ "confirm_token": "require_confirm_token",
15457
+ "confirmtoken": "require_confirm_token",
15458
+ "safe_mode": "safe_mode",
15459
+ "safemode": "safe_mode",
15460
+ "audit_log": "audit_log",
15461
+ "auditlog": "audit_log",
15462
+ "audit_log_path": "audit_log_path",
15463
+ "auditlogpath": "audit_log_path",
15464
+ }
15465
+ requested: Dict[str, Any] = {}
15466
+ for key, value in destructive_defaults.items():
15467
+ normalized_key = alias_to_key.get(_setup_text_key(key).replace("_", ""))
15468
+ if not normalized_key:
15469
+ normalized_key = alias_to_key.get(_setup_text_key(key))
15470
+ if normalized_key:
15471
+ requested[normalized_key] = value
15472
+ if not requested:
15473
+ return {"changed": False, "recognized": False}
15474
+
15475
+ try:
15476
+ preferences = _read_media_analysis_preferences_strict()
15477
+ except ConfigParseError as exc:
15478
+ return _err(f"Refusing to update destructive defaults: {exc}. The preferences file exists but is unparseable; fix or delete it to avoid wiping saved settings.")
15479
+
15480
+ before = _setup_destructive_defaults()
15481
+ next_preferences = dict(preferences)
15482
+ destructive = dict(next_preferences.get("destructive") if isinstance(next_preferences.get("destructive"), dict) else {})
15483
+ updates: Dict[str, Dict[str, Any]] = {}
15484
+
15485
+ def clear_requested(raw: Any) -> bool:
15486
+ return raw is None or (not isinstance(raw, bool) and _setup_text_key(raw) in _SETUP_CHOICE_CLEAR_VALUES)
15487
+
15488
+ for key, raw_value in requested.items():
15489
+ if clear_requested(raw_value):
15490
+ destructive.pop(key, None)
15491
+ updates[key] = {"before": before.get(key), "after": _setup_destructive_defaults().get(key), "cleared": True}
15492
+ elif key in {"require_confirm_token", "safe_mode", "audit_log"}:
15493
+ normalized = _setup_bool(raw_value, before.get(key, False))
15494
+ destructive[key] = normalized
15495
+ updates[key] = {"before": before.get(key), "after": normalized}
15496
+ elif key == "audit_log_path":
15497
+ path = os.path.realpath(os.path.abspath(os.path.expanduser(str(raw_value))))
15498
+ destructive[key] = path
15499
+ updates[key] = {"before": before.get(key), "after": path}
15500
+
15501
+ if dry_run:
15502
+ return {
15503
+ "changed": True,
15504
+ "recognized": True,
15505
+ "updates": updates,
15506
+ "before": before,
15507
+ "after": {**before, **{key: row.get("after") for key, row in updates.items()}},
15508
+ "dry_run": True,
15509
+ }
15510
+
15511
+ updated_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
15512
+ for key, row in updates.items():
15513
+ if not row.get("cleared"):
15514
+ destructive[f"{key}_updated_at"] = updated_at
15515
+ else:
15516
+ destructive.pop(f"{key}_updated_at", None)
15517
+ next_preferences["destructive"] = destructive
15518
+ _write_media_analysis_preferences(next_preferences)
15519
+ after = _setup_destructive_defaults()
15520
+ return {
15521
+ "changed": before != after,
15522
+ "recognized": True,
15523
+ "updates": updates,
15524
+ "before": before,
15525
+ "after": after,
15526
+ "updated_at": updated_at,
15527
+ "preferences_path": _media_analysis_preferences_path(),
15428
15528
  }
15429
15529
 
15430
15530
 
@@ -15939,6 +16039,25 @@ def _setup_clear_defaults(keys: Any, dry_run: bool) -> Dict[str, Any]:
15939
16039
  return result["media_analysis"]
15940
16040
  result["cleared"].extend(media_clear_keys[key] for key in media_payload)
15941
16041
 
16042
+ destructive_clear_keys = {
16043
+ "require_confirm_token": "destructive.require_confirm_token",
16044
+ "safe_mode": "destructive.safe_mode",
16045
+ "audit_log": "destructive.audit_log",
16046
+ "audit_log_path": "destructive.audit_log_path",
16047
+ }
16048
+ destructive_payload: Dict[str, Any] = {}
16049
+ if clear_all or "destructive" in normalized_keys:
16050
+ destructive_payload = {key: "clear" for key in destructive_clear_keys}
16051
+ else:
16052
+ for key, label in destructive_clear_keys.items():
16053
+ if key in normalized_keys or label in normalized_keys:
16054
+ destructive_payload[key] = "clear"
16055
+ if destructive_payload:
16056
+ result["destructive"] = _setup_set_destructive_defaults(destructive_payload, dry_run)
16057
+ if result["destructive"].get("error"):
16058
+ return result["destructive"]
16059
+ result["cleared"].extend(destructive_clear_keys[key] for key in destructive_payload)
16060
+
15942
16061
  if clear_all or normalized_keys & {"updates", "updates.mode", "update_mode", "mcp_update_policy"}:
15943
16062
  result["updates"] = _setup_set_updates_defaults({"mode": "prompt"}, dry_run)
15944
16063
  if result["updates"].get("error"):
@@ -15993,12 +16112,13 @@ def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
15993
16112
  Actions:
15994
16113
  schema() -> {defaults, actions}
15995
16114
  get_defaults() -> {defaults}
15996
- set_defaults(defaults?|media_analysis?|updates?|dry_run?) -> {defaults, changes}
16115
+ set_defaults(defaults?|media_analysis?|updates?|destructive?|dry_run?) -> {defaults, changes}
15997
16116
  clear_defaults(keys?, dry_run?) -> {defaults, cleared}
15998
16117
 
15999
16118
  Current defaults:
16000
16119
  media_analysis.*: analysis, metadata, marker, reporting, and workflow defaults
16001
16120
  updates.*: MCP update policy, interval, and snooze defaults
16121
+ destructive.*: confirm-token, safe-mode, and audit-log defaults
16002
16122
  """
16003
16123
  p = _params(params)
16004
16124
  if action in {"schema", "capabilities", "options"}:
@@ -16069,6 +16189,26 @@ def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
16069
16189
  "current": _get_envelope_mode(),
16070
16190
  "storage": _server_preferences_path(),
16071
16191
  },
16192
+ "destructive.require_confirm_token": {
16193
+ "description": "Require one-time confirmation tokens for registered high-risk destructive actions.",
16194
+ "values": [True, False],
16195
+ "storage": _media_analysis_preferences_path(),
16196
+ },
16197
+ "destructive.safe_mode": {
16198
+ "description": "When enabled, safe mode blocks high/dangerous destructive actions unless allow_risky_operation=true is passed.",
16199
+ "values": [True, False],
16200
+ "storage": _media_analysis_preferences_path(),
16201
+ },
16202
+ "destructive.audit_log": {
16203
+ "description": "Write JSONL security audit records for destructive operations.",
16204
+ "values": [True, False],
16205
+ "storage": _media_analysis_preferences_path(),
16206
+ },
16207
+ "destructive.audit_log_path": {
16208
+ "description": "Absolute path for the JSONL security audit log.",
16209
+ "values": "absolute or expandable path",
16210
+ "storage": _media_analysis_preferences_path(),
16211
+ },
16072
16212
  },
16073
16213
  }
16074
16214
 
@@ -16097,6 +16237,7 @@ def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
16097
16237
  for key, value in merged.items()
16098
16238
  if key not in {"updates", "mcp_updates", "mcpUpdates", "dry_run", "dryRun"}
16099
16239
  and key not in _general_keys
16240
+ and key != "destructive"
16100
16241
  },
16101
16242
  **({
16102
16243
  "timed_markers_default": _first_param(
@@ -16146,6 +16287,43 @@ def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
16146
16287
  )
16147
16288
  } if any(key in merged for key in ("snooze_hours", "snoozeHours", "update_snooze_hours", "updateSnoozeHours")) else {}),
16148
16289
  }
16290
+ destructive_defaults = {
16291
+ **_setup_nested(merged, "destructive"),
16292
+ **({
16293
+ "require_confirm_token": _first_param(
16294
+ merged,
16295
+ "require_confirm_token",
16296
+ "requireConfirmToken",
16297
+ "confirm_token",
16298
+ "confirmToken",
16299
+ default=None,
16300
+ )
16301
+ } if any(key in merged for key in ("require_confirm_token", "requireConfirmToken", "confirm_token", "confirmToken")) else {}),
16302
+ **({
16303
+ "safe_mode": _first_param(
16304
+ merged,
16305
+ "safe_mode",
16306
+ "safeMode",
16307
+ default=None,
16308
+ )
16309
+ } if any(key in merged for key in ("safe_mode", "safeMode")) else {}),
16310
+ **({
16311
+ "audit_log": _first_param(
16312
+ merged,
16313
+ "audit_log",
16314
+ "auditLog",
16315
+ default=None,
16316
+ )
16317
+ } if any(key in merged for key in ("audit_log", "auditLog")) else {}),
16318
+ **({
16319
+ "audit_log_path": _first_param(
16320
+ merged,
16321
+ "audit_log_path",
16322
+ "auditLogPath",
16323
+ default=None,
16324
+ )
16325
+ } if any(key in merged for key in ("audit_log_path", "auditLogPath")) else {}),
16326
+ }
16149
16327
 
16150
16328
  general_result = _setup_set_general_defaults(general_defaults, dry_run)
16151
16329
  if general_result.get("error"):
@@ -16156,9 +16334,16 @@ def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
16156
16334
  update_result = _setup_set_updates_defaults(update_defaults, dry_run)
16157
16335
  if update_result.get("error"):
16158
16336
  return update_result
16159
- recognized = (bool(general_result.get("recognized"))
16160
- or bool(media_result.get("recognized"))
16161
- or bool(update_result.get("recognized")))
16337
+ destructive_result = _setup_set_destructive_defaults(destructive_defaults, dry_run)
16338
+ if destructive_result.get("error"):
16339
+ return destructive_result
16340
+ recognized = (
16341
+ bool(general_result.get("recognized"))
16342
+ or
16343
+ bool(media_result.get("recognized"))
16344
+ or bool(update_result.get("recognized"))
16345
+ or bool(destructive_result.get("recognized"))
16346
+ )
16162
16347
  if not recognized:
16163
16348
  return _err("set_defaults did not receive a recognized default to set")
16164
16349
 
@@ -16168,6 +16353,7 @@ def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
16168
16353
  "general": general_result,
16169
16354
  "media_analysis": media_result,
16170
16355
  "updates": update_result,
16356
+ "destructive": destructive_result,
16171
16357
  },
16172
16358
  defaults=_setup_defaults_snapshot(),
16173
16359
  )
@@ -25,14 +25,26 @@ every tool that owns destructive actions is decorated.
25
25
  from __future__ import annotations
26
26
 
27
27
  import functools
28
+ import json
28
29
  import logging
30
+ import os
31
+ import time
32
+ import uuid
29
33
  from typing import Any, Callable, Dict, FrozenSet, Optional, Tuple
30
34
 
31
35
  from src.utils import analysis_runs, brain_edits, media_pool_changes, timeline_versioning
36
+ from src.utils.execution_lifecycle import RiskAssessment, RiskLevel, classify_operation_risk
32
37
 
33
38
  logger = logging.getLogger("resolve-mcp.destructive-hook")
34
39
 
35
40
 
41
+ #: Risk levels safe mode refuses. Names come from `RiskLevel`; a second
42
+ #: vocabulary here would let the gate and the reported level disagree.
43
+ SAFE_MODE_BLOCKED_RISK_LEVELS: FrozenSet[str] = frozenset({
44
+ RiskLevel.HIGH.value, RiskLevel.CRITICAL.value,
45
+ })
46
+
47
+
36
48
  # ── Destructive action registry ──────────────────────────────────────────────
37
49
  #
38
50
  # Tool name → frozenset of action strings that mutate the working timeline (or
@@ -300,6 +312,26 @@ def is_strict_required(tool_name: str, action: str, params: Optional[Dict[str, A
300
312
  return False
301
313
 
302
314
 
315
+ def assess_action_risk(
316
+ tool_name: str, action: str, params: Optional[Dict[str, Any]] = None,
317
+ ) -> RiskAssessment:
318
+ """Classify a call for the safe-operations policy.
319
+
320
+ Classification lives in `execution_lifecycle`, which the pre-flight
321
+ inspection surface already uses. Deriving it a second time here would let
322
+ `inspect_operation` and the safe-mode gate disagree about the same call —
323
+ the gate blocking what inspection called reversible, or the reverse.
324
+ """
325
+ return classify_operation_risk(tool_name, action, params or {})
326
+
327
+
328
+ def risk_level_for_action(
329
+ tool_name: str, action: str, params: Optional[Dict[str, Any]] = None,
330
+ ) -> str:
331
+ """Return the security risk level for a call, as a `RiskLevel` value."""
332
+ return assess_action_risk(tool_name, action, params).level.value
333
+
334
+
303
335
  # ── Provider hooks ───────────────────────────────────────────────────────────
304
336
 
305
337
  _ProjectRootProvider = Callable[[], Optional[Tuple[Any, Any, str, Optional[str]]]]
@@ -360,6 +392,163 @@ def _read_preference(key: str, default: Any = None) -> Any:
360
392
  return default if value is None else value
361
393
 
362
394
 
395
+ def _coerce_bool(value: Any, default: bool = False) -> bool:
396
+ if value is None:
397
+ return default
398
+ if isinstance(value, str):
399
+ lowered = value.strip().lower()
400
+ if lowered in {"1", "true", "yes", "on"}:
401
+ return True
402
+ if lowered in {"0", "false", "no", "off"}:
403
+ return False
404
+ return default
405
+ return bool(value)
406
+
407
+
408
+ def _safe_mode_enabled() -> bool:
409
+ return _coerce_bool(_read_preference("destructive.safe_mode", False), False)
410
+
411
+
412
+ def _audit_enabled() -> bool:
413
+ return _coerce_bool(_read_preference("destructive.audit_log", True), True)
414
+
415
+
416
+ def _audit_log_path() -> str:
417
+ configured = _read_preference("destructive.audit_log_path", None)
418
+ if configured:
419
+ return os.path.expanduser(str(configured))
420
+ project_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
421
+ return os.path.join(project_dir, "logs", "security-audit.jsonl")
422
+
423
+
424
+ def _safe_params_for_audit(params: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
425
+ if not isinstance(params, dict):
426
+ return None
427
+ redacted = dict(params)
428
+ for key in ("confirm_token", "confirmToken"):
429
+ if key in redacted:
430
+ redacted[key] = "<redacted>"
431
+ return redacted
432
+
433
+
434
+ def _audit_security_event(
435
+ *,
436
+ operation_id: str,
437
+ tool_name: str,
438
+ action: str,
439
+ risk_level: str,
440
+ status: str,
441
+ params: Optional[Dict[str, Any]],
442
+ recognised: bool = True,
443
+ reason: Optional[str] = None,
444
+ project_root: Optional[str] = None,
445
+ analysis_run_id: Optional[str] = None,
446
+ ) -> None:
447
+ if not _audit_enabled():
448
+ return
449
+ event = {
450
+ "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
451
+ "operation_id": operation_id,
452
+ "tool": tool_name,
453
+ "action": action,
454
+ "risk_level": risk_level,
455
+ "risk_established": recognised,
456
+ "status": status,
457
+ "reason": reason,
458
+ "analysis_run_id": analysis_run_id,
459
+ "project_root": project_root,
460
+ "params": _safe_params_for_audit(params),
461
+ }
462
+ path = _audit_log_path()
463
+ try:
464
+ os.makedirs(os.path.dirname(path), exist_ok=True)
465
+ with open(path, "a", encoding="utf-8") as handle:
466
+ handle.write(json.dumps(event, sort_keys=True, default=str) + "\n")
467
+ except Exception as exc:
468
+ logger.warning("security audit log write failed: %s", exc)
469
+
470
+
471
+ def _security_block_response(
472
+ *,
473
+ operation_id: str,
474
+ tool_name: str,
475
+ action: str,
476
+ risk_level: str,
477
+ recognised: bool = True,
478
+ ) -> Dict[str, Any]:
479
+ return {
480
+ "success": False,
481
+ "status": "blocked_by_security_policy",
482
+ "operation_id": operation_id,
483
+ "security": {
484
+ "risk_level": risk_level,
485
+ "risk_established": recognised,
486
+ "safe_mode": True,
487
+ "blocked": True,
488
+ "policy": "destructive.safe_mode",
489
+ },
490
+ "error": {
491
+ "message": (
492
+ f"Safe mode blocked {risk_level}-risk action "
493
+ f"'{tool_name}.{action}'. "
494
+ "Re-call with params.allow_risky_operation=true, or disable "
495
+ "destructive.safe_mode in setup defaults."
496
+ ),
497
+ "code": "SAFE_MODE_BLOCKED",
498
+ "category": "destructive_blocked",
499
+ "retryable": False,
500
+ "remediation": (
501
+ "Pass allow_risky_operation=true for this call after review, or "
502
+ "set destructive.safe_mode=false if this session should allow "
503
+ "high-risk destructive actions."
504
+ ),
505
+ },
506
+ }
507
+
508
+
509
+ def _annotate_security(
510
+ result: Any,
511
+ *,
512
+ operation_id: str,
513
+ risk_level: str,
514
+ blocked: bool = False,
515
+ recognised: bool = True,
516
+ ) -> Any:
517
+ if isinstance(result, dict):
518
+ result.setdefault("operation_id", operation_id)
519
+ result.setdefault("security", {
520
+ "risk_level": risk_level,
521
+ "risk_established": recognised,
522
+ "safe_mode": _safe_mode_enabled(),
523
+ "blocked": blocked,
524
+ })
525
+ return result
526
+
527
+
528
+ def _safe_mode_allows(
529
+ risk_level: str,
530
+ params: Optional[Dict[str, Any]],
531
+ recognised: bool = True,
532
+ ) -> bool:
533
+ """Whether safe mode lets this call through.
534
+
535
+ Only an established HIGH or CRITICAL is blocked. Failing closed on
536
+ `recognised=False` was measured and rejected: 80 of the 108 registered
537
+ destructive actions match no risk rule today, `timeline.add_track` and
538
+ `timeline.duplicate` among them, so it would block three quarters of all
539
+ edits and teach users to leave safe mode off — weaker in practice than a
540
+ narrow gate they keep on. The unclassified actions are reported via
541
+ `risk_established` instead, and want classifying rather than blanket-gating.
542
+ """
543
+ if not _safe_mode_enabled():
544
+ return True
545
+ if risk_level not in SAFE_MODE_BLOCKED_RISK_LEVELS:
546
+ return True
547
+ if isinstance(params, dict) and params.get("allow_risky_operation") is True:
548
+ return True
549
+ return False
550
+
551
+
363
552
  def _resolve_versioning_context() -> Optional[Tuple[Any, Any, str, Optional[str]]]:
364
553
  if _PROVIDER is None:
365
554
  return None
@@ -441,6 +630,29 @@ def destructive_op(tool_name: str) -> Callable[[Callable[..., Any]], Callable[..
441
630
  if not is_destructive(tool_name, action, params):
442
631
  return fn(action, params, *args, **kwargs)
443
632
 
633
+ operation_id = f"op_{uuid.uuid4().hex[:12]}"
634
+ assessment = assess_action_risk(tool_name, action, params)
635
+ risk_level = assessment.level.value
636
+ risk_recognised = assessment.recognised
637
+ if not _safe_mode_allows(risk_level, params, risk_recognised):
638
+ _audit_security_event(
639
+ operation_id=operation_id,
640
+ tool_name=tool_name,
641
+ action=action,
642
+ risk_level=risk_level,
643
+ status="blocked",
644
+ params=params,
645
+ reason="safe_mode",
646
+ recognised=risk_recognised,
647
+ )
648
+ return _security_block_response(
649
+ operation_id=operation_id,
650
+ tool_name=tool_name,
651
+ action=action,
652
+ risk_level=risk_level,
653
+ recognised=risk_recognised,
654
+ )
655
+
444
656
  # F4 — token-issuance calls don't mutate; skip the archive entirely
445
657
  # so that token preview/cancel paths don't litter the version chain.
446
658
  # The wrapper still annotates `_versioning` on the result so callers
@@ -453,30 +665,76 @@ def destructive_op(tool_name: str) -> Callable[[Callable[..., Any]], Callable[..
453
665
  will_gate = False
454
666
  if will_gate:
455
667
  result = fn(action, params, *args, **kwargs)
668
+ _audit_security_event(
669
+ operation_id=operation_id,
670
+ tool_name=tool_name,
671
+ action=action,
672
+ risk_level=risk_level,
673
+ status="pending_confirmation",
674
+ params=params,
675
+ reason="confirm_token_required",
676
+ recognised=risk_recognised,
677
+ )
456
678
  if isinstance(result, dict):
457
679
  result.setdefault("_versioning", {
458
680
  "analysis_run_id": None,
459
681
  "archived": False,
460
682
  "skipped_reason": "pending_confirm_token",
461
683
  })
462
- return result
684
+ return _annotate_security(
685
+ result,
686
+ operation_id=operation_id,
687
+ risk_level=risk_level,
688
+ recognised=risk_recognised,
689
+ )
463
690
 
464
691
  strict = is_strict_required(tool_name, action, params)
465
692
 
466
693
  ctx = _resolve_versioning_context()
467
694
  if ctx is None:
468
695
  if strict:
469
- return {
696
+ _audit_security_event(
697
+ operation_id=operation_id,
698
+ tool_name=tool_name,
699
+ action=action,
700
+ risk_level=risk_level,
701
+ status="blocked",
702
+ params=params,
703
+ reason="strict_missing_version_context",
704
+ recognised=risk_recognised,
705
+ )
706
+ return _annotate_security({
470
707
  "success": False,
471
708
  "error": (
472
709
  f"strict mode: '{tool_name}.{action}' refuses to run because the "
473
710
  "version-on-mutate context (project_root) couldn't be resolved. "
474
711
  "Open a project in Resolve, or pass strict=false to override."
475
712
  ),
476
- }
713
+ },
714
+ operation_id=operation_id,
715
+ risk_level=risk_level,
716
+ blocked=True,
717
+ recognised=risk_recognised,
718
+ )
477
719
  # No Resolve / no project — let the underlying handler run; it
478
720
  # will either succeed (e.g. in dry-run) or surface its own error.
479
- return fn(action, params, *args, **kwargs)
721
+ result = fn(action, params, *args, **kwargs)
722
+ _audit_security_event(
723
+ operation_id=operation_id,
724
+ tool_name=tool_name,
725
+ action=action,
726
+ risk_level=risk_level,
727
+ status="allowed",
728
+ params=params,
729
+ reason="no_version_context",
730
+ recognised=risk_recognised,
731
+ )
732
+ return _annotate_security(
733
+ result,
734
+ operation_id=operation_id,
735
+ risk_level=risk_level,
736
+ recognised=risk_recognised,
737
+ )
480
738
 
481
739
  resolve_h, project_h, project_root, project_name = ctx
482
740
  run_id = _extract_analysis_run_id(params, project_root=project_root)
@@ -488,6 +746,17 @@ def destructive_op(tool_name: str) -> Callable[[Callable[..., Any]], Callable[..
488
746
  # wrapper only handles the timeline path.
489
747
  if tool_name == "media_pool":
490
748
  result = fn(action, params, *args, **kwargs)
749
+ _audit_security_event(
750
+ operation_id=operation_id,
751
+ tool_name=tool_name,
752
+ action=action,
753
+ risk_level=risk_level,
754
+ status="allowed",
755
+ params=params,
756
+ project_root=project_root,
757
+ analysis_run_id=run_id,
758
+ recognised=risk_recognised,
759
+ )
491
760
  try:
492
761
  media_pool_changes.log_media_pool_change(
493
762
  project_root=project_root,
@@ -505,7 +774,12 @@ def destructive_op(tool_name: str) -> Callable[[Callable[..., Any]], Callable[..
505
774
  "category": "media_pool",
506
775
  "initiator": initiator,
507
776
  })
508
- return result
777
+ return _annotate_security(
778
+ result,
779
+ operation_id=operation_id,
780
+ risk_level=risk_level,
781
+ recognised=risk_recognised,
782
+ )
509
783
 
510
784
  before_value: Optional[float] = None
511
785
  timeline_before_name: Optional[str] = None
@@ -555,7 +829,19 @@ def destructive_op(tool_name: str) -> Callable[[Callable[..., Any]], Callable[..
555
829
  if archive_exc is not None or (
556
830
  not archive_result.get("archived") and not skipped_ok
557
831
  ):
558
- return {
832
+ _audit_security_event(
833
+ operation_id=operation_id,
834
+ tool_name=tool_name,
835
+ action=action,
836
+ risk_level=risk_level,
837
+ status="blocked",
838
+ params=params,
839
+ reason="strict_archive_failed",
840
+ project_root=project_root,
841
+ analysis_run_id=run_id,
842
+ recognised=risk_recognised,
843
+ )
844
+ return _annotate_security({
559
845
  "success": False,
560
846
  "error": (
561
847
  f"strict mode: refused '{tool_name}.{action}' because the "
@@ -568,10 +854,26 @@ def destructive_op(tool_name: str) -> Callable[[Callable[..., Any]], Callable[..
568
854
  "archived": False,
569
855
  "strict_block": True,
570
856
  },
571
- }
857
+ },
858
+ operation_id=operation_id,
859
+ risk_level=risk_level,
860
+ blocked=True,
861
+ recognised=risk_recognised,
862
+ )
572
863
 
573
864
  # Run the underlying handler regardless of hook outcome.
574
865
  result = fn(action, params, *args, **kwargs)
866
+ _audit_security_event(
867
+ operation_id=operation_id,
868
+ tool_name=tool_name,
869
+ action=action,
870
+ risk_level=risk_level,
871
+ status="allowed",
872
+ params=params,
873
+ project_root=project_root,
874
+ analysis_run_id=run_id,
875
+ recognised=risk_recognised,
876
+ )
575
877
 
576
878
  after_value: Optional[float] = None
577
879
  timeline_after_name: Optional[str] = timeline_before_name
@@ -617,7 +919,12 @@ def destructive_op(tool_name: str) -> Callable[[Callable[..., Any]], Callable[..
617
919
  "before_value": before_value,
618
920
  "after_value": after_value,
619
921
  })
620
- return result
922
+ return _annotate_security(
923
+ result,
924
+ operation_id=operation_id,
925
+ risk_level=risk_level,
926
+ recognised=risk_recognised,
927
+ )
621
928
 
622
929
  wrapper.__wrapped_tool_name__ = tool_name # type: ignore[attr-defined]
623
930
  wrapper.__is_destructive_wrapped__ = True # type: ignore[attr-defined]
@@ -29,7 +29,7 @@ logger = logging.getLogger("resolve-mcp.execution-lifecycle")
29
29
 
30
30
  class RiskLevel(str, enum.Enum):
31
31
  """Categorized risk level for tool operations."""
32
- LOW = "low" # Read-only queries, info probes, status checks
32
+ LOW = "low" # Read-only queries, info probes, and known-reversible edits
33
33
  MEDIUM = "medium" # Reversible edits, markers, non-destructive properties
34
34
  HIGH = "high" # Deletions, ripples, timeline restructuring, batch edits
35
35
  CRITICAL = "critical" # Project deletion, database resets, permanent loss
@@ -139,6 +139,26 @@ class RiskClassificationHook(LifecycleHook):
139
139
  ("edit_engine", "auto_cut_silence"),
140
140
  ("edit_engine", "ripple_trim"),
141
141
  ("project_manager", "save_project_as"),
142
+ ("media_pool", "delete_folders"),
143
+ ("timeline", "delete_track"),
144
+ ("timeline", "lift_range"),
145
+ ("timeline", "overwrite_range"),
146
+ ("timeline", "apply_cuts"),
147
+ ("graph", "reset_all_grades"),
148
+ }
149
+
150
+ #: Mutating, but bounded and trivially reversible — a marker or a clip
151
+ #: colour. Without this table the name heuristic files them under MEDIUM
152
+ #: and flags them unrecognised, i.e. it warns that the risk is unestablished
153
+ #: for the actions whose risk is the best established of any we dispatch.
154
+ _LOW_RISK_ACTIONS: Set[Tuple[str, str]] = {
155
+ ("timeline_markers", "add"),
156
+ ("timeline_markers", "update_custom_data"),
157
+ ("timeline_item_markers", "add"),
158
+ ("timeline_item_markers", "add_flag"),
159
+ ("timeline_item_markers", "clear_flags"),
160
+ ("timeline_item_markers", "set_clip_color"),
161
+ ("timeline_item_markers", "clear_clip_color"),
142
162
  }
143
163
 
144
164
  _READ_ONLY_PREFIXES = ("get_", "list_", "query_", "probe_", "inspect_", "export_", "check_")
@@ -170,6 +190,11 @@ class RiskClassificationHook(LifecycleHook):
170
190
  radius = BlastRadius.ITEM
171
191
  conf_required = True
172
192
  reasons.append(f"Destructive timeline edit: {action}")
193
+ elif pair in cls._LOW_RISK_ACTIONS:
194
+ level = RiskLevel.LOW
195
+ destructive = True
196
+ radius = BlastRadius.ITEM
197
+ reasons.append(f"Bounded reversible edit: {action}")
173
198
  elif any(action.startswith(p) for p in cls._READ_ONLY_PREFIXES) or action in {"read", "status", "info"}:
174
199
  level = RiskLevel.LOW
175
200
  destructive = False