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 +81 -0
- package/README.md +1 -1
- package/README.zh-CN.md +2 -2
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/granular/common.py +1 -1
- package/src/server.py +281 -42
- package/src/utils/destructive_hook.py +315 -8
- package/src/utils/execution_lifecycle.py +26 -1
- package/src/utils/resolve_bridge_client.py +39 -0
- package/src/utils/resolve_bridge_ops.py +72 -6
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
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
8
|
[-blue.svg)](#server-modes)
|
package/README.zh-CN.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.md) | 简体中文
|
|
4
4
|
|
|
5
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
8
|
[-blue.svg)](#服务器模式)
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
[](https://www.python.org/downloads/)
|
|
13
13
|
[](https://opensource.org/licenses/MIT)
|
|
14
14
|
|
|
15
|
-
> 本翻译对应 v2.
|
|
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.
|
|
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
package/src/granular/common.py
CHANGED
|
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
|
|
|
87
87
|
handlers=[logging.StreamHandler()],
|
|
88
88
|
)
|
|
89
89
|
|
|
90
|
-
VERSION = "2.
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
16080
|
-
|
|
16081
|
-
|
|
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
|
-
|
|
24176
|
-
|
|
24177
|
-
|
|
24178
|
-
|
|
24179
|
-
|
|
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
|
-
|
|
24313
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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."""
|