davinci-resolve-mcp 2.208.0 → 2.209.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,87 @@
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.0 — safe operations policy
6
+
7
+ ### Added
8
+
9
+ - **Destructive operations now carry explicit security metadata** — wrapped
10
+ destructive tool calls receive an `operation_id` plus a `security` block with
11
+ a `risk_level` (`low`, `medium`, `high`, or `critical`) and a
12
+ `risk_established` flag. The existing version-on-mutate and confirm-token
13
+ gates stay intact, but callers now have a stable policy surface to inspect
14
+ and display before or after a Resolve mutation.
15
+
16
+ Levels come from the same classifier that backs pre-flight
17
+ `inspect_operation`, so the gate and the inspection surface cannot disagree
18
+ about a call. `risk_established` is false when the classifier matched no rule
19
+ and the level is a name-based default rather than a finding — true today for
20
+ 80 of the 108 registered destructive actions, which is a gap to close by
21
+ classifying them, not by gating them.
22
+ - **Safe mode blocks high-risk destructive calls when enabled** —
23
+ `setup(action="set_defaults", params={"destructive": {"safe_mode": true}})`
24
+ blocks `high` and `critical` actions before the underlying Resolve handler
25
+ runs. Reviewed one-off calls can proceed with `allow_risky_operation=true`.
26
+ Unclassified actions are reported, not blocked.
27
+ - **Security audit JSONL for destructive calls** — allowed, blocked, and
28
+ pending-confirmation destructive calls write audit events to
29
+ `logs/security-audit.jsonl` by default. Confirmation tokens are redacted in
30
+ the audit payload.
31
+
32
+ ### Changed
33
+
34
+ - **Risk classification is now one table, not two** — the marker and
35
+ clip-colour actions classify as `low` instead of falling through the name
36
+ heuristic as unrecognised `medium`, and `timeline.lift_range`,
37
+ `timeline.overwrite_range`, `timeline.apply_cuts`, `timeline.delete_track`,
38
+ `media_pool.delete_folders` and `graph.reset_all_grades` are now classified
39
+ `high` wherever risk is reported, including pre-flight inspection.
40
+ - **`setup` exposes destructive defaults** — `destructive.require_confirm_token`,
41
+ `destructive.safe_mode`, `destructive.audit_log`, and
42
+ `destructive.audit_log_path` are now visible through `schema`, persisted by
43
+ `set_defaults`, and reset by `clear_defaults`.
44
+
45
+ ## What's New in v2.208.1 — #188: variant item counts come from the timeline
46
+
47
+ ### Fixed
48
+
49
+ - **A silence ripple under-reported what it built, by exactly half.**
50
+ `execute_silence_ripple` returned `variant_video_items: 250` and
51
+ `variant_audio_items: 250` for a variant that really held 432 of each. The
52
+ bridge's `ResolveOperations._encode` truncated every proxied container to
53
+ `max_items` (500) with no signal anywhere, and `plan_silence_ripple`
54
+ interleaves video and audio — so a 432-range plan became 864 clipInfos in one
55
+ `AppendToTimeline`, Resolve placed and returned all 864, and the first 500
56
+ encoded are precisely 250 video plus 250 audio. The same response's
57
+ `readback.after.clip_count` said 864 and was right the whole time, because it
58
+ re-reads per track: two numbers from two sources in one payload, one of them
59
+ silently short. "Planned 432, got 250" reads exactly like 182 ranges failing
60
+ to land, which on a silence ripple is the operator's central fear, and
61
+ establishing that it was benign cost a full review cycle of hand-auditing
62
+ both tracks. Reported and fixed in #188 by @mart0vip.
63
+ - **Dropped elements are now reported, never silent.** `op_call` and
64
+ `op_get_attribute` carry a `truncated` block naming the count, limit and
65
+ containers; the client records it on `transport.truncations` and logs the
66
+ method. It warns rather than raises deliberately — the native call has
67
+ already run by the time the reply is encoded, so raising would turn a
68
+ completed 864-item assembly into an error and orphan the timeline. A short
69
+ list that looks complete was the failure mode; the bound itself is
70
+ legitimate.
71
+ - **The item ceiling no longer exceeds the handle table.** `max_items` was
72
+ clamped to 5000 against a 4096-entry `MAX_HANDLES`, so a long enough list
73
+ evicted its own earliest handles while it was still being minted and handed
74
+ the client ids that were already `stale_handle`. It now clamps to
75
+ `MAX_HANDLES`, with the default raised 500 → 2000.
76
+ - **Counts come from the timeline, not the append reply.**
77
+ `create_variant_from_ranges` reports `placed_item_counts` from the
78
+ post-assembly per-track re-read it was already taking for gap detection — no
79
+ extra Resolve calls — and `execute_silence_ripple` and `execute_tighten` now
80
+ share one accounting helper, tighten having carried the identical bug. A
81
+ planned-vs-placed disagreement is stated outright instead of left to a hand
82
+ audit.
83
+ - Beyond reporting: under the old ceiling a `cdl` applied to a large variant
84
+ only reached the first 250 video items.
85
+
5
86
  ## What's New in v2.208.0 — agent execution lifecycle & pre-flight risk inspection
6
87
 
7
88
  Adapted from the design contributed in PR #187.
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.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.209.0-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.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.209.0-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.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v2.209.0 版 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.0"
40
+ VERSION = "2.209.0"
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.0",
3
+ "version": "2.209.0",
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.0"
90
+ VERSION = "2.209.0"
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.0"
14
+ VERSION = "2.209.0"
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
 
@@ -6606,6 +6611,26 @@ def _variant_item_placement(item) -> Dict[str, Any]:
6606
6611
  }
6607
6612
 
6608
6613
 
6614
+ def _snapshot_track_item_counts(snapshot: Dict[str, Any]) -> Dict[str, int]:
6615
+ """Per-track-type item counts read from a conform snapshot of a live timeline.
6616
+
6617
+ This is the ONLY honest answer to "what did the assembly actually place".
6618
+ The obvious alternative — counting what `MediaPool.AppendToTimeline`
6619
+ returned — is a witness derived from the same call it would be checking, and
6620
+ it lies in two measured ways: the in-app bridge caps any proxied list at
6621
+ `max_items` (an 864-clipInfo append came back as 500 items, so a variant
6622
+ holding 432 video + 432 audio was reported as 250 + 250), and Resolve drops
6623
+ colliding records from the reply without an error (see the api_truth entry
6624
+ "MediaPool.AppendToTimeline (overlapping records — earlier item wins)").
6625
+ Re-reading the timeline per track cannot be fooled by either.
6626
+ """
6627
+ counts: Dict[str, int] = {}
6628
+ for track_type, block in (snapshot.get("tracks") or {}).items():
6629
+ rows = (block or {}).get("tracks") or []
6630
+ counts[str(track_type)] = sum(int(row.get("item_count") or 0) for row in rows)
6631
+ return counts
6632
+
6633
+
6609
6634
  def _variant_audio_summary(built):
6610
6635
  """Video/audio range counts for an assembled variant, warning when it carries
6611
6636
  no audio. create_variant_from_ranges places exactly the ranges given, so a
@@ -6618,6 +6643,61 @@ def _variant_audio_summary(built):
6618
6643
  return summary
6619
6644
 
6620
6645
 
6646
+ def _variant_audio_accounting(variant: Dict[str, Any], *, planned_video: int,
6647
+ planned_audio: int) -> Dict[str, Any]:
6648
+ """The planned-vs-placed block on a tighten / silence-ripple readback.
6649
+
6650
+ Shared by execute_tighten and execute_silence_ripple so the two cannot
6651
+ drift: they answer the same operator question, "did every range I planned
6652
+ actually land in the variant".
6653
+
6654
+ Placed counts come from the assembler's post-assembly re-read of the
6655
+ timeline, never from what `AppendToTimeline` returned — see
6656
+ `_snapshot_track_item_counts` for why the append's reply is not evidence.
6657
+ A count that is short for a *reporting* reason and a count that is short
6658
+ because material was dropped must never look the same here: on a silence
6659
+ ripple the operator's whole fear is dropped material, so a disagreement is
6660
+ stated outright rather than left to be discovered by hand-auditing tracks.
6661
+ """
6662
+ placed = variant.get("placed_item_counts")
6663
+ video = (placed or {}).get("video")
6664
+ audio = (placed or {}).get("audio")
6665
+ accounting: Dict[str, Any] = {
6666
+ "planned_audio_ranges": planned_audio,
6667
+ "planned_video_ranges": planned_video,
6668
+ "variant_audio_items": audio,
6669
+ "variant_video_items": video,
6670
+ "counts_source": "post-assembly per-track read of the variant timeline",
6671
+ }
6672
+ if video is None or audio is None:
6673
+ accounting["note"] = (
6674
+ "Placed item counts are UNAVAILABLE — the variant could not be re-read "
6675
+ "after assembly. Verify with timeline_item get_items_in_track before "
6676
+ "using this variant."
6677
+ )
6678
+ return accounting
6679
+ disagreements = []
6680
+ if video != planned_video:
6681
+ disagreements.append(f"video {video}/{planned_video}")
6682
+ if audio != planned_audio:
6683
+ disagreements.append(f"audio {audio}/{planned_audio}")
6684
+ if disagreements:
6685
+ accounting["note"] = (
6686
+ "PLACED COUNT DISAGREES WITH THE PLAN (placed/planned: "
6687
+ + ", ".join(disagreements)
6688
+ + ") — ranges did not land. Resolve drops colliding records from an "
6689
+ "append without erroring; check readback.gaps_overlaps and the "
6690
+ "tracks themselves before using this variant."
6691
+ )
6692
+ elif planned_audio:
6693
+ accounting["note"] = "Variant carries audio mirrored from the video cuts."
6694
+ else:
6695
+ accounting["note"] = (
6696
+ "Variant is VIDEO-ONLY (silent) — re-plan with include_audio=True for sound."
6697
+ )
6698
+ return accounting
6699
+
6700
+
6621
6701
  def _timeline_create_variant_from_ranges(proj, source_tl, p: Dict[str, Any]) -> Dict[str, Any]:
6622
6702
  ranges = p.get("ranges") or p.get("clip_infos")
6623
6703
  if not isinstance(ranges, list) or not ranges:
@@ -6768,16 +6848,21 @@ def _timeline_create_variant_from_ranges(proj, source_tl, p: Dict[str, Any]) ->
6768
6848
  if p.get("cdl"):
6769
6849
  target_ids = [row.get("timeline_item_id") for row in items_out if row.get("timeline_item_id") and row.get("range", {}).get("media_type") == 1]
6770
6850
  look_result = _timeline_apply_look_to_items(new_tl, {"target_ids": target_ids, "cdl": p.get("cdl")})
6851
+ # One snapshot, two consumers: gap detection and the placed-item counts.
6852
+ # `items` above is only as complete as the append's REPLY, so it is not
6853
+ # evidence of what landed — `placed_item_counts` re-reads the timeline.
6854
+ snapshot = _timeline_conform_snapshot(new_tl, {})
6771
6855
  return {
6772
6856
  "success": True,
6773
6857
  "name": new_tl.GetName(),
6774
6858
  "id": new_tl.GetUniqueId(),
6775
6859
  "items": items_out,
6860
+ "placed_item_counts": _snapshot_track_item_counts(snapshot),
6776
6861
  "placement_mismatches": placement_mismatches,
6777
6862
  "audio": _variant_audio_summary(built),
6778
6863
  "markers": marker_results,
6779
6864
  "look": look_result,
6780
- "gaps_overlaps": _detect_gaps_overlaps_from_snapshot(_timeline_conform_snapshot(new_tl, {}), {}),
6865
+ "gaps_overlaps": _detect_gaps_overlaps_from_snapshot(snapshot, {}),
6781
6866
  }
6782
6867
 
6783
6868
 
@@ -15345,6 +15430,101 @@ def _setup_defaults_snapshot() -> Dict[str, Any]:
15345
15430
  "general": _setup_general_defaults(),
15346
15431
  "media_analysis": _setup_media_analysis_defaults(),
15347
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(),
15348
15528
  }
15349
15529
 
15350
15530
 
@@ -15859,6 +16039,25 @@ def _setup_clear_defaults(keys: Any, dry_run: bool) -> Dict[str, Any]:
15859
16039
  return result["media_analysis"]
15860
16040
  result["cleared"].extend(media_clear_keys[key] for key in media_payload)
15861
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
+
15862
16061
  if clear_all or normalized_keys & {"updates", "updates.mode", "update_mode", "mcp_update_policy"}:
15863
16062
  result["updates"] = _setup_set_updates_defaults({"mode": "prompt"}, dry_run)
15864
16063
  if result["updates"].get("error"):
@@ -15913,12 +16112,13 @@ def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
15913
16112
  Actions:
15914
16113
  schema() -> {defaults, actions}
15915
16114
  get_defaults() -> {defaults}
15916
- set_defaults(defaults?|media_analysis?|updates?|dry_run?) -> {defaults, changes}
16115
+ set_defaults(defaults?|media_analysis?|updates?|destructive?|dry_run?) -> {defaults, changes}
15917
16116
  clear_defaults(keys?, dry_run?) -> {defaults, cleared}
15918
16117
 
15919
16118
  Current defaults:
15920
16119
  media_analysis.*: analysis, metadata, marker, reporting, and workflow defaults
15921
16120
  updates.*: MCP update policy, interval, and snooze defaults
16121
+ destructive.*: confirm-token, safe-mode, and audit-log defaults
15922
16122
  """
15923
16123
  p = _params(params)
15924
16124
  if action in {"schema", "capabilities", "options"}:
@@ -15989,6 +16189,26 @@ def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
15989
16189
  "current": _get_envelope_mode(),
15990
16190
  "storage": _server_preferences_path(),
15991
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
+ },
15992
16212
  },
15993
16213
  }
15994
16214
 
@@ -16017,6 +16237,7 @@ def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
16017
16237
  for key, value in merged.items()
16018
16238
  if key not in {"updates", "mcp_updates", "mcpUpdates", "dry_run", "dryRun"}
16019
16239
  and key not in _general_keys
16240
+ and key != "destructive"
16020
16241
  },
16021
16242
  **({
16022
16243
  "timed_markers_default": _first_param(
@@ -16066,6 +16287,43 @@ def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
16066
16287
  )
16067
16288
  } if any(key in merged for key in ("snooze_hours", "snoozeHours", "update_snooze_hours", "updateSnoozeHours")) else {}),
16068
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
+ }
16069
16327
 
16070
16328
  general_result = _setup_set_general_defaults(general_defaults, dry_run)
16071
16329
  if general_result.get("error"):
@@ -16076,9 +16334,16 @@ def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
16076
16334
  update_result = _setup_set_updates_defaults(update_defaults, dry_run)
16077
16335
  if update_result.get("error"):
16078
16336
  return update_result
16079
- recognized = (bool(general_result.get("recognized"))
16080
- or bool(media_result.get("recognized"))
16081
- 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
+ )
16082
16347
  if not recognized:
16083
16348
  return _err("set_defaults did not receive a recognized default to set")
16084
16349
 
@@ -16088,6 +16353,7 @@ def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
16088
16353
  "general": general_result,
16089
16354
  "media_analysis": media_result,
16090
16355
  "updates": update_result,
16356
+ "destructive": destructive_result,
16091
16357
  },
16092
16358
  defaults=_setup_defaults_snapshot(),
16093
16359
  )
@@ -24172,24 +24438,11 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
24172
24438
  structural_diff if include_details
24173
24439
  else _compact_structural_diff(structural_diff)
24174
24440
  ),
24175
- "audio_accounting": {
24176
- "planned_audio_ranges": audio_keep_ranges,
24177
- "planned_video_ranges": video_keep_ranges,
24178
- # variant_* count placed items; variant["audio"] counts requested ranges.
24179
- "variant_audio_items": sum(
24180
- 1 for it in (variant.get("items") or [])
24181
- if (it.get("range") or {}).get("media_type") == 2
24182
- ),
24183
- "variant_video_items": sum(
24184
- 1 for it in (variant.get("items") or [])
24185
- if (it.get("range") or {}).get("media_type") == 1
24186
- ),
24187
- "note": (
24188
- "Variant carries audio mirrored from the video cuts."
24189
- if audio_keep_ranges
24190
- else "Variant is VIDEO-ONLY (silent) — re-plan with include_audio=True for sound."
24191
- ),
24192
- },
24441
+ # variant_* count PLACED items, re-read from the variant;
24442
+ # variant["audio"] counts requested ranges.
24443
+ "audio_accounting": _variant_audio_accounting(
24444
+ variant, planned_video=video_keep_ranges, planned_audio=audio_keep_ranges,
24445
+ ),
24193
24446
  },
24194
24447
  "plan_id": plan.get("plan_id"),
24195
24448
  }
@@ -24308,23 +24561,9 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
24308
24561
  structural_diff if include_details
24309
24562
  else _compact_structural_diff(structural_diff)
24310
24563
  ),
24311
- "audio_accounting": {
24312
- "planned_audio_ranges": audio_keep_ranges,
24313
- "planned_video_ranges": video_keep_ranges,
24314
- "variant_audio_items": sum(
24315
- 1 for it in (variant.get("items") or [])
24316
- if (it.get("range") or {}).get("media_type") == 2
24317
- ),
24318
- "variant_video_items": sum(
24319
- 1 for it in (variant.get("items") or [])
24320
- if (it.get("range") or {}).get("media_type") == 1
24321
- ),
24322
- "note": (
24323
- "Variant carries audio mirrored from the video cuts."
24324
- if audio_keep_ranges
24325
- else "Variant is VIDEO-ONLY (silent) — re-plan with include_audio=True for sound."
24326
- ),
24327
- },
24564
+ "audio_accounting": _variant_audio_accounting(
24565
+ variant, planned_video=video_keep_ranges, planned_audio=audio_keep_ranges,
24566
+ ),
24328
24567
  },
24329
24568
  "plan_id": plan.get("plan_id"),
24330
24569
  }
@@ -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
@@ -33,6 +33,18 @@ proxy deliberately does not.
33
33
  timeline item-by-item is the shape that bites.
34
34
  - **Bridge absence.** If the in-Resolve script is not running, construction fails
35
35
  with a clear message rather than pretending; there is nothing to fall back to.
36
+ - **Incomplete replies.** A returned container longer than the surface's
37
+ `max_items` comes back short. That used to be invisible, and a short list is
38
+ indistinguishable from a genuinely short result — an 864-clipInfo
39
+ `AppendToTimeline` returned 500 items and a caller counted them as the whole
40
+ answer. The surface now reports every drop and `_BoundMethod` surfaces it
41
+ (`transport.truncations`, plus a warning naming the method).
42
+
43
+ It warns rather than raising, deliberately: the native call has already *run*
44
+ by the time the reply is encoded, so raising would turn completed Resolve work
45
+ — a placed 864-item assembly — into an error and orphan the result. The honest
46
+ handling is for the caller to stop treating a returned list as a count, which
47
+ is why the tools that report item counts re-read them from the timeline.
36
48
  """
37
49
 
38
50
  from __future__ import annotations
@@ -117,6 +129,31 @@ class BridgeTransport:
117
129
  # keeps request/response pairing simple and matches the _bridge_lock
118
130
  # discipline the rest of the server already follows.
119
131
  self._lock = threading.RLock()
132
+ #: Replies the surface reported as incomplete, newest last: one row per
133
+ #: (method, dropped, total). Kept so a caller that suspects a short
134
+ #: enumeration can prove it instead of inferring it from a count that
135
+ #: looks plausible. Bounded — this is a diagnostic, not a log.
136
+ self.truncations: List[Dict[str, Any]] = []
137
+
138
+ def note_truncation(self, method: str, truncated: Any) -> None:
139
+ """Record and announce a reply the surface could not carry in full."""
140
+ if not isinstance(truncated, dict):
141
+ return
142
+ row = {"method": method, **truncated}
143
+ with self._lock:
144
+ self.truncations.append(row)
145
+ del self.truncations[:-32]
146
+ # Shapes here come off the wire from a bridge that may be older than
147
+ # this client, so nothing is indexed or assumed present.
148
+ containers = truncated.get("containers")
149
+ first = containers[0] if isinstance(containers, list) and containers else {}
150
+ logger.warning(
151
+ "bridge reply for %s was TRUNCATED: %s of %s elements dropped (limit %s). "
152
+ "The returned list is not a count — re-read the object instead.",
153
+ method, truncated.get("dropped"),
154
+ first.get("total") if isinstance(first, dict) else None,
155
+ truncated.get("limit"),
156
+ )
120
157
 
121
158
  def request(self, operation: str, arguments: Dict[str, Any]) -> Any:
122
159
  payload = {
@@ -251,6 +288,7 @@ class _BoundMethod:
251
288
  {"target": self._handle, "method": self._name,
252
289
  "args": [_encode_argument(a) for a in args]},
253
290
  )
291
+ self._transport.note_truncation(self._name, (result or {}).get("truncated"))
254
292
  return _decode_value(self._transport, (result or {}).get("value"))
255
293
 
256
294
  def __repr__(self) -> str: # pragma: no cover - debugging aid
@@ -345,6 +383,7 @@ class BridgeProxy:
345
383
  probe = self._transport.request("get_attribute",
346
384
  {"target": self._handle, "name": name}) or {}
347
385
  if probe.get("kind") == "value":
386
+ self._transport.note_truncation(name, probe.get("truncated"))
348
387
  return _decode_value(self._transport, probe.get("value"))
349
388
  if (name in _FUSION_UNENUMERATED_METHODS
350
389
  and self._methods() & _FUSION_OBJECT_MARKERS):
@@ -174,13 +174,22 @@ class ResolveOperations:
174
174
  #: loses a handle gets a clear `stale_handle` error and can re-fetch.
175
175
  MAX_HANDLES = 4096
176
176
 
177
+ #: Elements carried out of one encoded return value. A timeline-scale
178
+ #: enumeration has to fit: the old 500 silently halved an 864-item
179
+ #: `AppendToTimeline` return, and the caller counted the 500 it got as the
180
+ #: whole truth (a 432+432 variant read back as 250+250, issue: silence-ripple
181
+ #: audio_accounting). Whatever the ceiling is, exceeding it is now REPORTED
182
+ #: — see `_encode` — because a short list that looks complete is the failure
183
+ #: mode, not the bound itself.
184
+ DEFAULT_MAX_ITEMS = 2000
185
+
177
186
  def __init__(
178
187
  self,
179
188
  resolve: Any,
180
189
  *,
181
190
  media_roots: List[str],
182
191
  output_roots: List[str],
183
- max_items: int = 500,
192
+ max_items: int = DEFAULT_MAX_ITEMS,
184
193
  lifecycle: Optional[Callable[[str], Dict[str, Any]]] = None,
185
194
  ) -> None:
186
195
  if resolve is None:
@@ -191,7 +200,16 @@ class ResolveOperations:
191
200
  # pretending to stop something they have no handle on.
192
201
  self._lifecycle = lifecycle
193
202
  self.policy = PathPolicy(media_roots, output_roots)
194
- self.max_items = max(1, min(int(max_items), 5000))
203
+ # Capped at MAX_HANDLES, not at some larger round number: every live
204
+ # object in an encoded list mints a handle, so a list longer than the
205
+ # table evicts its own earliest entries before the client can use them
206
+ # and hands back handles that are already `stale_handle`. A ceiling
207
+ # above the table would trade a short list for a poisoned one.
208
+ self.max_items = max(1, min(int(max_items), self.MAX_HANDLES))
209
+ #: Set by `_encode` when a return value did not fit, read by the ops
210
+ #: that encode. Not a counter across calls — it answers "was THIS reply
211
+ #: complete", which is the only question a caller can act on.
212
+ self._encode_truncation: List[Dict[str, Any]] = []
195
213
  self._routes: Dict[str, Callable[[Dict[str, Any]], Any]] = {
196
214
  name: getattr(self, f"op_{name}") for name in self.OPERATIONS
197
215
  }
@@ -507,21 +525,68 @@ class ResolveOperations:
507
525
  Every object produced by one call carries the same shape, including the
508
526
  elements of a returned list — a track's timeline items are homogeneous,
509
527
  which is exactly the case where sharing a cached method set pays.
528
+
529
+ **Dropping elements is recorded, never silent.** A container longer than
530
+ `max_items` used to come back shortened with nothing anywhere saying so,
531
+ and a short list is indistinguishable from a genuinely short result: an
532
+ 864-clipInfo `AppendToTimeline` returned 500 items, the caller counted
533
+ them, and a variant holding 432 video + 432 audio was reported to the
534
+ operator as 250 + 250 — which reads exactly like 182 ranges failing to
535
+ land. The bound itself is legitimate (see `max_items`); hiding it is
536
+ not, so every drop is reported alongside the value.
510
537
  """
511
538
  if value is None or isinstance(value, (bool, int, float, str)):
512
539
  return value
513
540
  if depth > 6:
514
541
  return str(value)
515
542
  if isinstance(value, (list, tuple)):
516
- return [self._encode(v, depth + 1, shape) for v in list(value)[: self.max_items]]
543
+ items = list(value)
544
+ self._note_truncation(len(items), "list", depth, shape)
545
+ return [self._encode(v, depth + 1, shape) for v in items[: self.max_items]]
517
546
  if isinstance(value, dict):
518
- return {str(k): self._encode(v, depth + 1, shape) for k, v in list(value.items())[: self.max_items]}
547
+ pairs = list(value.items())
548
+ self._note_truncation(len(pairs), "dict", depth, shape)
549
+ return {str(k): self._encode(v, depth + 1, shape) for k, v in pairs[: self.max_items]}
519
550
  return {
520
551
  "__handle__": self._mint(value, shape),
521
552
  "__type__": type(value).__name__,
522
553
  "__shape__": shape,
523
554
  }
524
555
 
556
+ def _note_truncation(self, total: int, kind: str, depth: int, shape: str) -> None:
557
+ if total <= self.max_items:
558
+ return
559
+ self._encode_truncation.append({
560
+ "shape": shape, "kind": kind, "depth": depth,
561
+ "returned": self.max_items, "total": total,
562
+ "dropped": total - self.max_items,
563
+ })
564
+
565
+ def _encoded(self, value: Any, shape: str) -> Dict[str, Any]:
566
+ """Encode one return value into a reply, carrying any truncation with it.
567
+
568
+ The `truncated` block is the whole point: a caller that reads `value` as
569
+ a complete answer is wrong exactly when this key is present, and it
570
+ cannot know that from the value alone.
571
+ """
572
+ self._encode_truncation = []
573
+ encoded = self._encode(value, shape=shape)
574
+ reply: Dict[str, Any] = {"value": encoded}
575
+ if self._encode_truncation:
576
+ dropped = sum(row["dropped"] for row in self._encode_truncation)
577
+ reply["truncated"] = {
578
+ "dropped": dropped,
579
+ "limit": self.max_items,
580
+ "containers": self._encode_truncation[:8],
581
+ "hint": (
582
+ "This reply is INCOMPLETE — the value is not evidence of how many "
583
+ "items exist. Re-read in smaller pieces, or count from the object "
584
+ "itself rather than from this list."
585
+ ),
586
+ }
587
+ self._encode_truncation = []
588
+ return reply
589
+
525
590
  def _decode(self, value: Any) -> Any:
526
591
  """Argument -> live object, rehydrating handles the bridge itself issued."""
527
592
  if isinstance(value, dict):
@@ -571,7 +636,7 @@ class ResolveOperations:
571
636
  "resolve_raised",
572
637
  f"Resolve raised while running {method}: {str(exc)[:200]}",
573
638
  )
574
- return {"value": self._encode(result, shape=f"{self._shape_of(target_key)}.{method}")}
639
+ return self._encoded(result, shape=f"{self._shape_of(target_key)}.{method}")
575
640
 
576
641
  def op_list_methods(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
577
642
  """The public attribute names on a target — what `hasattr` should answer.
@@ -690,7 +755,8 @@ class ResolveOperations:
690
755
  return {"kind": "none",
691
756
  "note": "present-but-None; on Resolve objects this is indistinguishable "
692
757
  "from absent, because getattr never raises"}
693
- return {"kind": "value", "value": self._encode(value, shape=f"{self._shape_of(arguments.get('target', 'resolve'))}.{name}")}
758
+ encoded = self._encoded(value, shape=f"{self._shape_of(arguments.get('target', 'resolve'))}.{name}")
759
+ return {"kind": "value", **encoded}
694
760
 
695
761
  def op_release_handles(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
696
762
  """Drop handles a client no longer needs, or all of them."""