davinci-resolve-mcp 4.4.2 → 4.5.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 +86 -0
- package/README.md +2 -2
- package/README.zh-CN.md +3 -3
- package/docs/SKILL.md +15 -0
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/granular/common.py +2 -1
- package/src/granular/folder.py +4 -0
- package/src/granular/gallery.py +4 -0
- package/src/granular/graph.py +4 -0
- package/src/granular/media_pool.py +6 -0
- package/src/granular/media_pool_item.py +21 -0
- package/src/granular/project.py +28 -0
- package/src/granular/resolve_211.py +9 -0
- package/src/granular/resolve_control.py +9 -0
- package/src/granular/timeline.py +18 -0
- package/src/granular/timeline_item.py +30 -0
- package/src/server.py +1 -1
- package/src/utils/destructive_hook.py +216 -3
|
@@ -231,6 +231,7 @@ def get_timeline_items() -> List[Dict[str, Any]]:
|
|
|
231
231
|
|
|
232
232
|
|
|
233
233
|
@mcp.tool(annotations=DESTRUCTIVE_TOOL)
|
|
234
|
+
@granular_destructive_op()
|
|
234
235
|
def set_timeline_item_transform(timeline_item_id: str,
|
|
235
236
|
property_name: str,
|
|
236
237
|
property_value: float) -> str:
|
|
@@ -294,6 +295,7 @@ def set_timeline_item_transform(timeline_item_id: str,
|
|
|
294
295
|
|
|
295
296
|
|
|
296
297
|
@mcp.tool()
|
|
298
|
+
@granular_destructive_op()
|
|
297
299
|
def set_timeline_item_crop(timeline_item_id: str,
|
|
298
300
|
crop_type: str,
|
|
299
301
|
crop_value: float) -> str:
|
|
@@ -354,6 +356,7 @@ def set_timeline_item_crop(timeline_item_id: str,
|
|
|
354
356
|
|
|
355
357
|
|
|
356
358
|
@mcp.tool()
|
|
359
|
+
@granular_destructive_op()
|
|
357
360
|
def set_timeline_item_composite(timeline_item_id: str,
|
|
358
361
|
composite_mode: str = None,
|
|
359
362
|
opacity: float = None) -> str:
|
|
@@ -441,6 +444,7 @@ def set_timeline_item_composite(timeline_item_id: str,
|
|
|
441
444
|
|
|
442
445
|
|
|
443
446
|
@mcp.tool()
|
|
447
|
+
@granular_destructive_op()
|
|
444
448
|
def set_timeline_item_retime(timeline_item_id: str,
|
|
445
449
|
speed: float = None,
|
|
446
450
|
process: str = None) -> str:
|
|
@@ -519,6 +523,7 @@ def set_timeline_item_retime(timeline_item_id: str,
|
|
|
519
523
|
|
|
520
524
|
|
|
521
525
|
@mcp.tool()
|
|
526
|
+
@granular_destructive_op()
|
|
522
527
|
def set_timeline_item_stabilization(timeline_item_id: str,
|
|
523
528
|
enabled: bool = None,
|
|
524
529
|
method: str = None,
|
|
@@ -610,6 +615,7 @@ def set_timeline_item_stabilization(timeline_item_id: str,
|
|
|
610
615
|
|
|
611
616
|
|
|
612
617
|
@mcp.tool()
|
|
618
|
+
@granular_destructive_op()
|
|
613
619
|
def set_timeline_item_audio(timeline_item_id: str,
|
|
614
620
|
volume: float = None,
|
|
615
621
|
pan: float = None,
|
|
@@ -1057,6 +1063,7 @@ def modify_keyframe(timeline_item_id: str, property_name: str, frame: int, new_v
|
|
|
1057
1063
|
|
|
1058
1064
|
|
|
1059
1065
|
@mcp.tool()
|
|
1066
|
+
@granular_destructive_op()
|
|
1060
1067
|
def delete_keyframe(timeline_item_id: str, property_name: str, frame: int) -> str:
|
|
1061
1068
|
"""Delete a keyframe at the specified frame for a timeline item property.
|
|
1062
1069
|
|
|
@@ -1135,6 +1142,7 @@ def delete_keyframe(timeline_item_id: str, property_name: str, frame: int) -> st
|
|
|
1135
1142
|
|
|
1136
1143
|
|
|
1137
1144
|
@mcp.tool()
|
|
1145
|
+
@granular_destructive_op()
|
|
1138
1146
|
def set_keyframe_interpolation(timeline_item_id: str, property_name: str, frame: int, interpolation_type: str) -> str:
|
|
1139
1147
|
"""Set the interpolation type for a keyframe.
|
|
1140
1148
|
|
|
@@ -1325,6 +1333,7 @@ def ti_get_info(item_index: int = 0, track_type: str = "video", track_index: int
|
|
|
1325
1333
|
|
|
1326
1334
|
|
|
1327
1335
|
@mcp.tool()
|
|
1336
|
+
@granular_destructive_op()
|
|
1328
1337
|
def ti_set_name(name: str, item_index: int = 0, track_type: str = "video", track_index: int = 1) -> Dict[str, Any]:
|
|
1329
1338
|
"""Rename a timeline item.
|
|
1330
1339
|
|
|
@@ -1360,6 +1369,7 @@ def ti_get_source_start_time(item_index: int = 0, track_type: str = "video", tra
|
|
|
1360
1369
|
|
|
1361
1370
|
|
|
1362
1371
|
@mcp.tool()
|
|
1372
|
+
@granular_destructive_op()
|
|
1363
1373
|
def ti_set_property(property_name: str, property_value: Any, item_index: int = 0, track_type: str = "video", track_index: int = 1) -> Dict[str, Any]:
|
|
1364
1374
|
"""Set a property on a timeline item.
|
|
1365
1375
|
|
|
@@ -1435,6 +1445,7 @@ def ti_get_markers(item_index: int = 0, track_type: str = "video", track_index:
|
|
|
1435
1445
|
|
|
1436
1446
|
|
|
1437
1447
|
@mcp.tool()
|
|
1448
|
+
@granular_destructive_op()
|
|
1438
1449
|
def ti_delete_markers_by_color(color: str, item_index: int = 0, track_type: str = "video", track_index: int = 1) -> Dict[str, Any]:
|
|
1439
1450
|
"""Delete markers by color on a timeline item.
|
|
1440
1451
|
|
|
@@ -1449,6 +1460,7 @@ def ti_delete_markers_by_color(color: str, item_index: int = 0, track_type: str
|
|
|
1449
1460
|
|
|
1450
1461
|
|
|
1451
1462
|
@mcp.tool()
|
|
1463
|
+
@granular_destructive_op()
|
|
1452
1464
|
def ti_delete_marker_at_frame(frame_id: int, item_index: int = 0, track_type: str = "video", track_index: int = 1) -> Dict[str, Any]:
|
|
1453
1465
|
"""Delete a marker at a frame on a timeline item.
|
|
1454
1466
|
|
|
@@ -1463,6 +1475,7 @@ def ti_delete_marker_at_frame(frame_id: int, item_index: int = 0, track_type: st
|
|
|
1463
1475
|
|
|
1464
1476
|
|
|
1465
1477
|
@mcp.tool()
|
|
1478
|
+
@granular_destructive_op()
|
|
1466
1479
|
def ti_delete_marker_by_custom_data(custom_data: str, item_index: int = 0, track_type: str = "video", track_index: int = 1) -> Dict[str, Any]:
|
|
1467
1480
|
"""Delete a marker by custom data on a timeline item.
|
|
1468
1481
|
|
|
@@ -1547,6 +1560,7 @@ def ti_get_flag_list(item_index: int = 0, track_type: str = "video", track_index
|
|
|
1547
1560
|
|
|
1548
1561
|
|
|
1549
1562
|
@mcp.tool()
|
|
1563
|
+
@granular_destructive_op()
|
|
1550
1564
|
def ti_clear_flags(color: str = "", item_index: int = 0, track_type: str = "video", track_index: int = 1) -> Dict[str, Any]:
|
|
1551
1565
|
"""Clear flags from a timeline item.
|
|
1552
1566
|
|
|
@@ -1574,6 +1588,7 @@ def ti_get_clip_color(item_index: int = 0, track_type: str = "video", track_inde
|
|
|
1574
1588
|
|
|
1575
1589
|
|
|
1576
1590
|
@mcp.tool()
|
|
1591
|
+
@granular_destructive_op()
|
|
1577
1592
|
def ti_set_clip_color(color: str, item_index: int = 0, track_type: str = "video", track_index: int = 1) -> Dict[str, Any]:
|
|
1578
1593
|
"""Set clip color of a timeline item.
|
|
1579
1594
|
|
|
@@ -1613,6 +1628,7 @@ def ti_set_clip_color(color: str, item_index: int = 0, track_type: str = "video"
|
|
|
1613
1628
|
|
|
1614
1629
|
|
|
1615
1630
|
@mcp.tool()
|
|
1631
|
+
@granular_destructive_op()
|
|
1616
1632
|
def ti_clear_clip_color(item_index: int = 0, track_type: str = "video", track_index: int = 1) -> Dict[str, Any]:
|
|
1617
1633
|
"""Clear clip color from a timeline item.
|
|
1618
1634
|
|
|
@@ -1671,6 +1687,7 @@ def ti_export_fusion_comp(file_path: str, comp_index: int = 1, item_index: int =
|
|
|
1671
1687
|
|
|
1672
1688
|
|
|
1673
1689
|
@mcp.tool()
|
|
1690
|
+
@granular_destructive_op()
|
|
1674
1691
|
def ti_delete_fusion_comp(comp_name: str, item_index: int = 0, track_type: str = "video", track_index: int = 1) -> Dict[str, Any]:
|
|
1675
1692
|
"""Delete a Fusion composition by name.
|
|
1676
1693
|
|
|
@@ -1685,6 +1702,7 @@ def ti_delete_fusion_comp(comp_name: str, item_index: int = 0, track_type: str =
|
|
|
1685
1702
|
|
|
1686
1703
|
|
|
1687
1704
|
@mcp.tool()
|
|
1705
|
+
@granular_destructive_op()
|
|
1688
1706
|
def ti_load_fusion_comp(comp_name: str, item_index: int = 0, track_type: str = "video", track_index: int = 1) -> Dict[str, Any]:
|
|
1689
1707
|
"""Load a Fusion composition by name.
|
|
1690
1708
|
|
|
@@ -1758,6 +1776,7 @@ def ti_get_current_version(item_index: int = 0, track_type: str = "video", track
|
|
|
1758
1776
|
|
|
1759
1777
|
|
|
1760
1778
|
@mcp.tool()
|
|
1779
|
+
@granular_destructive_op()
|
|
1761
1780
|
def ti_delete_version(version_name: str, version_type: int = 0, item_index: int = 0, track_type: str = "video", track_index: int = 1) -> Dict[str, Any]:
|
|
1762
1781
|
"""Delete a color version.
|
|
1763
1782
|
|
|
@@ -1773,6 +1792,7 @@ def ti_delete_version(version_name: str, version_type: int = 0, item_index: int
|
|
|
1773
1792
|
|
|
1774
1793
|
|
|
1775
1794
|
@mcp.tool()
|
|
1795
|
+
@granular_destructive_op()
|
|
1776
1796
|
def ti_load_version(version_name: str, version_type: int = 0, item_index: int = 0, track_type: str = "video", track_index: int = 1) -> Dict[str, Any]:
|
|
1777
1797
|
"""Load a color version.
|
|
1778
1798
|
|
|
@@ -1818,6 +1838,7 @@ def ti_get_version_name_list(version_type: int = 0, item_index: int = 0, track_t
|
|
|
1818
1838
|
|
|
1819
1839
|
|
|
1820
1840
|
@mcp.tool()
|
|
1841
|
+
@granular_destructive_op()
|
|
1821
1842
|
def ti_set_cdl(cdl: Dict[str, Any], item_index: int = 0, track_type: str = "video", track_index: int = 1) -> Dict[str, Any]:
|
|
1822
1843
|
"""Set CDL (Color Decision List) values on a timeline item.
|
|
1823
1844
|
|
|
@@ -1887,6 +1908,7 @@ def ti_select_take(take_index: int, item_index: int = 0, track_type: str = "vide
|
|
|
1887
1908
|
|
|
1888
1909
|
|
|
1889
1910
|
@mcp.tool()
|
|
1911
|
+
@granular_destructive_op()
|
|
1890
1912
|
def ti_delete_take(take_index: int, item_index: int = 0, track_type: str = "video", track_index: int = 1) -> Dict[str, Any]:
|
|
1891
1913
|
"""Delete a take by index.
|
|
1892
1914
|
|
|
@@ -1914,6 +1936,7 @@ def ti_finalize_take(item_index: int = 0, track_type: str = "video", track_index
|
|
|
1914
1936
|
|
|
1915
1937
|
|
|
1916
1938
|
@mcp.tool(annotations=DESTRUCTIVE_TOOL)
|
|
1939
|
+
@granular_destructive_op()
|
|
1917
1940
|
def ti_copy_grades(
|
|
1918
1941
|
target_item_indices: List[int],
|
|
1919
1942
|
track_type: str = "video",
|
|
@@ -2016,6 +2039,7 @@ def ti_copy_grades(
|
|
|
2016
2039
|
|
|
2017
2040
|
|
|
2018
2041
|
@mcp.tool()
|
|
2042
|
+
@granular_destructive_op()
|
|
2019
2043
|
def ti_set_clip_enabled(enabled: bool, item_index: int = 0, track_type: str = "video", track_index: int = 1) -> Dict[str, Any]:
|
|
2020
2044
|
"""Enable or disable a timeline item.
|
|
2021
2045
|
|
|
@@ -2043,6 +2067,7 @@ def ti_update_sidecar(item_index: int = 0, track_type: str = "video", track_inde
|
|
|
2043
2067
|
|
|
2044
2068
|
|
|
2045
2069
|
@mcp.tool()
|
|
2070
|
+
@granular_destructive_op()
|
|
2046
2071
|
def ti_load_burn_in_preset(preset_name: str, item_index: int = 0, track_type: str = "video", track_index: int = 1) -> Dict[str, Any]:
|
|
2047
2072
|
"""Load a burn-in preset for a timeline item.
|
|
2048
2073
|
|
|
@@ -2146,6 +2171,7 @@ def ti_get_voice_isolation_state(item_index: int = 0, track_type: str = "audio",
|
|
|
2146
2171
|
|
|
2147
2172
|
|
|
2148
2173
|
@mcp.tool()
|
|
2174
|
+
@granular_destructive_op()
|
|
2149
2175
|
def ti_set_voice_isolation_state(state: Dict[str, Any], item_index: int = 0, track_type: str = "audio", track_index: int = 1) -> Dict[str, Any]:
|
|
2150
2176
|
"""Set voice isolation state for a timeline item.
|
|
2151
2177
|
|
|
@@ -2166,6 +2192,7 @@ def ti_set_voice_isolation_state(state: Dict[str, Any], item_index: int = 0, tra
|
|
|
2166
2192
|
|
|
2167
2193
|
|
|
2168
2194
|
@mcp.tool()
|
|
2195
|
+
@granular_destructive_op()
|
|
2169
2196
|
def ti_reset_all_node_colors(item_index: int = 0, track_type: str = "video", track_index: int = 1) -> Dict[str, Any]:
|
|
2170
2197
|
"""Reset node colors for all nodes in the active clip version.
|
|
2171
2198
|
|
|
@@ -2241,6 +2268,7 @@ def ti_assign_to_color_group(group_name: str, item_index: int = 0, track_type: s
|
|
|
2241
2268
|
|
|
2242
2269
|
|
|
2243
2270
|
@mcp.tool()
|
|
2271
|
+
@granular_destructive_op()
|
|
2244
2272
|
def ti_remove_from_color_group(item_index: int = 0, track_type: str = "video", track_index: int = 1) -> Dict[str, Any]:
|
|
2245
2273
|
"""Remove a timeline item from its color group.
|
|
2246
2274
|
|
|
@@ -2379,6 +2407,7 @@ def ti_get_cache_status(item_index: int = 0, track_type: str = "video", track_in
|
|
|
2379
2407
|
|
|
2380
2408
|
|
|
2381
2409
|
@mcp.tool()
|
|
2410
|
+
@granular_destructive_op()
|
|
2382
2411
|
def ti_set_color_output_cache(enabled: bool, item_index: int = 0, track_type: str = "video", track_index: int = 1) -> Dict[str, Any]:
|
|
2383
2412
|
"""Enable/disable color output cache for a timeline item.
|
|
2384
2413
|
|
|
@@ -2393,6 +2422,7 @@ def ti_set_color_output_cache(enabled: bool, item_index: int = 0, track_type: st
|
|
|
2393
2422
|
|
|
2394
2423
|
|
|
2395
2424
|
@mcp.tool()
|
|
2425
|
+
@granular_destructive_op()
|
|
2396
2426
|
def ti_set_fusion_output_cache(enabled: bool, item_index: int = 0, track_type: str = "video", track_index: int = 1) -> Dict[str, Any]:
|
|
2397
2427
|
"""Enable/disable Fusion output cache for a timeline item.
|
|
2398
2428
|
|
package/src/server.py
CHANGED
|
@@ -24,16 +24,19 @@ every tool that owns destructive actions is decorated.
|
|
|
24
24
|
|
|
25
25
|
from __future__ import annotations
|
|
26
26
|
|
|
27
|
+
import ast
|
|
27
28
|
import functools
|
|
29
|
+
import inspect
|
|
28
30
|
import json
|
|
29
31
|
import logging
|
|
30
32
|
import os
|
|
33
|
+
import textwrap
|
|
31
34
|
import time
|
|
32
35
|
import uuid
|
|
33
36
|
from typing import Any, Callable, Dict, FrozenSet, Optional, Tuple
|
|
34
37
|
|
|
35
38
|
from src.utils import analysis_runs, brain_edits, media_pool_changes, timeline_versioning
|
|
36
|
-
from src.utils.api_truth import traps_for, trap_notice
|
|
39
|
+
from src.utils.api_truth import API_TRUTH, traps_for, trap_notice
|
|
37
40
|
from src.utils.bool_params import coerce_bool, explicit_bool_param
|
|
38
41
|
from src.utils.execution_lifecycle import RiskAssessment, RiskLevel, classify_operation_risk
|
|
39
42
|
|
|
@@ -643,6 +646,7 @@ def _security_block_response(
|
|
|
643
646
|
action: str,
|
|
644
647
|
risk_level: str,
|
|
645
648
|
recognised: bool = True,
|
|
649
|
+
override_hint: str = "params.allow_risky_operation=true",
|
|
646
650
|
) -> Dict[str, Any]:
|
|
647
651
|
return {
|
|
648
652
|
"success": False,
|
|
@@ -659,14 +663,14 @@ def _security_block_response(
|
|
|
659
663
|
"message": (
|
|
660
664
|
f"Safe mode blocked {risk_level}-risk action "
|
|
661
665
|
f"'{tool_name}.{action}'. "
|
|
662
|
-
"Re-call with
|
|
666
|
+
f"Re-call with {override_hint}, or disable "
|
|
663
667
|
"destructive.safe_mode in setup defaults."
|
|
664
668
|
),
|
|
665
669
|
"code": "SAFE_MODE_BLOCKED",
|
|
666
670
|
"category": "destructive_blocked",
|
|
667
671
|
"retryable": False,
|
|
668
672
|
"remediation": (
|
|
669
|
-
"Pass
|
|
673
|
+
f"Pass {override_hint} for this call after review, or "
|
|
670
674
|
"set destructive.safe_mode=false if this session should allow "
|
|
671
675
|
"high-risk destructive actions."
|
|
672
676
|
),
|
|
@@ -857,6 +861,215 @@ def _attach_trap_notices(result: Any, traps: list) -> Any:
|
|
|
857
861
|
return result
|
|
858
862
|
|
|
859
863
|
|
|
864
|
+
# ── Granular server enforcement ──────────────────────────────────────────────
|
|
865
|
+
#
|
|
866
|
+
# `destructive_op` above wraps a `(action, params)` signature. Granular tools do not
|
|
867
|
+
# have one: each tool IS the operation, with its own keyword arguments. For four
|
|
868
|
+
# releases that meant safe mode — the setting a user turns on precisely so that a
|
|
869
|
+
# HIGH-risk call is refused — did nothing whatsoever on the granular server, and no
|
|
870
|
+
# granular write produced an audit row. A user running with `destructive.safe_mode`
|
|
871
|
+
# on was protected on one server and not the other, with nothing saying so.
|
|
872
|
+
#
|
|
873
|
+
# This decorator closes that. It deliberately does NOT archive: the compound hook
|
|
874
|
+
# duplicates a timeline into an Archive bin before a mutation, and doing that around
|
|
875
|
+
# 130-odd granular calls would bury a project in versions for operations as small
|
|
876
|
+
# as setting a clip colour. What it does is the part that was missing and cannot be
|
|
877
|
+
# recovered after the fact — refuse when policy says refuse, and record what ran.
|
|
878
|
+
# A granular write therefore stays UNRECOVERABLE after it runs; the docs say so
|
|
879
|
+
# rather than implying parity with the compound server.
|
|
880
|
+
|
|
881
|
+
#: Verb → risk level, matching how the compound tables rate the same verbs
|
|
882
|
+
#: (deletes and removes HIGH, sets and loads MEDIUM). Only HIGH and CRITICAL are
|
|
883
|
+
#: refused by safe mode, so this mapping is what makes the setting mean anything
|
|
884
|
+
#: on this surface.
|
|
885
|
+
#:
|
|
886
|
+
#: Values are `RiskLevel` VALUES, not names. The first draft wrote "HIGH" here while
|
|
887
|
+
#: `SAFE_MODE_BLOCKED_RISK_LEVELS` holds `RiskLevel.HIGH.value == "high"`, so nothing
|
|
888
|
+
#: ever matched and safe mode was cosmetic on every decorated tool. One vocabulary.
|
|
889
|
+
GRANULAR_RISK_BY_VERB: Dict[str, str] = {
|
|
890
|
+
"delete": RiskLevel.HIGH.value,
|
|
891
|
+
"remove": RiskLevel.HIGH.value,
|
|
892
|
+
"clear": RiskLevel.HIGH.value,
|
|
893
|
+
"reset": RiskLevel.HIGH.value,
|
|
894
|
+
"replace": RiskLevel.HIGH.value,
|
|
895
|
+
"unlink": RiskLevel.HIGH.value,
|
|
896
|
+
"overwrite": RiskLevel.HIGH.value,
|
|
897
|
+
"quit": RiskLevel.HIGH.value,
|
|
898
|
+
"restart": RiskLevel.HIGH.value,
|
|
899
|
+
"set": RiskLevel.MEDIUM.value,
|
|
900
|
+
"load": RiskLevel.MEDIUM.value,
|
|
901
|
+
"switch": RiskLevel.MEDIUM.value,
|
|
902
|
+
"close": RiskLevel.MEDIUM.value,
|
|
903
|
+
"stop": RiskLevel.MEDIUM.value,
|
|
904
|
+
"lift": RiskLevel.MEDIUM.value,
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
#: Namespaces stripped before reading the verb, kept in step with
|
|
908
|
+
#: `src.granular.common.NAMESPACE_PREFIXES`.
|
|
909
|
+
_GRANULAR_NAMESPACES = ("ti_", "timeline_", "graph_", "folder_")
|
|
910
|
+
|
|
911
|
+
#: Resolve method names the ledger marks `destroys_prior_work`. Derived from
|
|
912
|
+
#: `API_TRUTH` rather than written out, so flagging a new entry raises every tool
|
|
913
|
+
#: that reaches it without anyone remembering this table exists.
|
|
914
|
+
GRANULAR_TRAP_METHODS: FrozenSet[str] = frozenset(
|
|
915
|
+
str(entry["symbol"]).rsplit(".", 1)[-1]
|
|
916
|
+
for entry in API_TRUTH
|
|
917
|
+
if entry.get("destroys_prior_work")
|
|
918
|
+
)
|
|
919
|
+
|
|
920
|
+
#: Name of the per-call safe-mode override, the same one the compound server reads
|
|
921
|
+
#: from `params`. Granular tools have no `params` dict, so the decorator adds it to
|
|
922
|
+
#: the tool's own signature.
|
|
923
|
+
GRANULAR_OVERRIDE_PARAM = "allow_risky_operation"
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def _reaches_trap_symbol(fn: Callable[..., Any]) -> bool:
|
|
927
|
+
"""Does the function body call a method the ledger says destroys prior work?
|
|
928
|
+
|
|
929
|
+
Read from source with `ast`, the same way the ratchet test finds it, so the
|
|
930
|
+
rating is mechanical: a tool that reaches `TimelineItem.CopyGrades` is HIGH
|
|
931
|
+
because the ledger says so, not because someone rated it by hand.
|
|
932
|
+
"""
|
|
933
|
+
if not GRANULAR_TRAP_METHODS:
|
|
934
|
+
return False
|
|
935
|
+
try:
|
|
936
|
+
source = inspect.getsource(fn)
|
|
937
|
+
except (OSError, TypeError):
|
|
938
|
+
return False
|
|
939
|
+
try:
|
|
940
|
+
tree = ast.parse(textwrap.dedent(source))
|
|
941
|
+
except SyntaxError:
|
|
942
|
+
return False
|
|
943
|
+
for node in ast.walk(tree):
|
|
944
|
+
if (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
|
|
945
|
+
and node.func.attr in GRANULAR_TRAP_METHODS):
|
|
946
|
+
return True
|
|
947
|
+
return False
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def granular_risk_level(tool_name: str, fn: Optional[Callable[..., Any]] = None) -> str:
|
|
951
|
+
"""Rate a granular tool: HIGH from the ledger if it reaches a trap symbol,
|
|
952
|
+
otherwise from its verb. Unknown verbs are MEDIUM, never HIGH.
|
|
953
|
+
|
|
954
|
+
Rating an unrecognised verb HIGH would let safe mode block writes nobody has
|
|
955
|
+
assessed, which is the failure `_safe_mode_allows` documents: over-blocking
|
|
956
|
+
teaches people to switch the setting off, and a setting left off protects
|
|
957
|
+
nothing.
|
|
958
|
+
"""
|
|
959
|
+
if fn is not None and _reaches_trap_symbol(fn):
|
|
960
|
+
return RiskLevel.HIGH.value
|
|
961
|
+
name = (tool_name or "").lower()
|
|
962
|
+
for prefix in _GRANULAR_NAMESPACES:
|
|
963
|
+
if name.startswith(prefix):
|
|
964
|
+
name = name[len(prefix):]
|
|
965
|
+
break
|
|
966
|
+
return GRANULAR_RISK_BY_VERB.get(name.split("_", 1)[0], RiskLevel.MEDIUM.value)
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
def _with_override_param(fn: Callable[..., Any]) -> Tuple[inspect.Signature, bool]:
|
|
970
|
+
"""The tool's signature plus `allow_risky_operation: bool = False`.
|
|
971
|
+
|
|
972
|
+
FastMCP builds the MCP input schema from `inspect.signature`, which honours
|
|
973
|
+
`__signature__`, so this is what makes the override callable from a client.
|
|
974
|
+
Returns (signature, added): `added` is False when the tool already declares
|
|
975
|
+
the parameter itself, in which case it is passed straight through.
|
|
976
|
+
"""
|
|
977
|
+
sig = inspect.signature(fn)
|
|
978
|
+
if GRANULAR_OVERRIDE_PARAM in sig.parameters:
|
|
979
|
+
return sig, False
|
|
980
|
+
extra = inspect.Parameter(
|
|
981
|
+
GRANULAR_OVERRIDE_PARAM,
|
|
982
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
983
|
+
default=False,
|
|
984
|
+
annotation=bool,
|
|
985
|
+
)
|
|
986
|
+
params = list(sig.parameters.values())
|
|
987
|
+
# Keyword-only goes after every positional-or-keyword parameter and before
|
|
988
|
+
# **kwargs, if the tool has one.
|
|
989
|
+
tail = [p for p in params if p.kind is inspect.Parameter.VAR_KEYWORD]
|
|
990
|
+
head = [p for p in params if p.kind is not inspect.Parameter.VAR_KEYWORD]
|
|
991
|
+
return sig.replace(parameters=head + [extra] + tail), True
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def granular_destructive_op(
|
|
995
|
+
tool_name: Optional[str] = None,
|
|
996
|
+
*,
|
|
997
|
+
risk_level: Optional[str] = None,
|
|
998
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
999
|
+
"""Apply safe-mode policy and audit logging to one granular tool.
|
|
1000
|
+
|
|
1001
|
+
Wraps an arbitrary keyword signature rather than `(action, params)`. The call's
|
|
1002
|
+
bound arguments become the `params` every downstream helper expects, and an
|
|
1003
|
+
`allow_risky_operation` parameter is added to the tool's schema so a single
|
|
1004
|
+
HIGH-risk call can be let through with safe mode still on — the same override
|
|
1005
|
+
the compound server reads from `params`.
|
|
1006
|
+
|
|
1007
|
+
No archive is taken. A granular write cannot be recovered after it runs.
|
|
1008
|
+
"""
|
|
1009
|
+
|
|
1010
|
+
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
1011
|
+
action = tool_name or getattr(fn, "__name__", "unknown")
|
|
1012
|
+
level = risk_level or granular_risk_level(action, fn)
|
|
1013
|
+
signature, added_override = _with_override_param(fn)
|
|
1014
|
+
|
|
1015
|
+
@functools.wraps(fn)
|
|
1016
|
+
def _inner(*args: Any, **kwargs: Any) -> Any:
|
|
1017
|
+
override = False
|
|
1018
|
+
if added_override and GRANULAR_OVERRIDE_PARAM in kwargs:
|
|
1019
|
+
override = _coerce_bool(kwargs.pop(GRANULAR_OVERRIDE_PARAM), False)
|
|
1020
|
+
# Bind so a positionally-passed argument is still audited by name. A
|
|
1021
|
+
# signature mismatch must not turn a working tool into an error, so
|
|
1022
|
+
# fall back to the keywords we were given.
|
|
1023
|
+
try:
|
|
1024
|
+
bound = inspect.signature(fn).bind_partial(*args, **kwargs)
|
|
1025
|
+
bound.apply_defaults()
|
|
1026
|
+
params: Dict[str, Any] = dict(bound.arguments)
|
|
1027
|
+
except Exception:
|
|
1028
|
+
params = dict(kwargs)
|
|
1029
|
+
if added_override:
|
|
1030
|
+
params[GRANULAR_OVERRIDE_PARAM] = override
|
|
1031
|
+
elif GRANULAR_OVERRIDE_PARAM in params:
|
|
1032
|
+
params[GRANULAR_OVERRIDE_PARAM] = _coerce_bool(
|
|
1033
|
+
params[GRANULAR_OVERRIDE_PARAM], False)
|
|
1034
|
+
|
|
1035
|
+
operation_id = f"op_{uuid.uuid4().hex[:12]}"
|
|
1036
|
+
if not _safe_mode_allows(level, params, True):
|
|
1037
|
+
_audit_security_event(
|
|
1038
|
+
operation_id=operation_id,
|
|
1039
|
+
tool_name="granular",
|
|
1040
|
+
action=action,
|
|
1041
|
+
risk_level=level,
|
|
1042
|
+
status="blocked",
|
|
1043
|
+
params=params,
|
|
1044
|
+
reason="safe_mode",
|
|
1045
|
+
)
|
|
1046
|
+
return _security_block_response(
|
|
1047
|
+
operation_id=operation_id,
|
|
1048
|
+
tool_name="granular",
|
|
1049
|
+
action=action,
|
|
1050
|
+
risk_level=level,
|
|
1051
|
+
override_hint=f"{GRANULAR_OVERRIDE_PARAM}=true",
|
|
1052
|
+
)
|
|
1053
|
+
|
|
1054
|
+
result = fn(*args, **kwargs)
|
|
1055
|
+
_audit_security_event(
|
|
1056
|
+
operation_id=operation_id,
|
|
1057
|
+
tool_name="granular",
|
|
1058
|
+
action=action,
|
|
1059
|
+
risk_level=level,
|
|
1060
|
+
status="allowed",
|
|
1061
|
+
params=params,
|
|
1062
|
+
reason="no_archive_on_granular",
|
|
1063
|
+
)
|
|
1064
|
+
return _annotate_security(result, operation_id=operation_id, risk_level=level)
|
|
1065
|
+
|
|
1066
|
+
_inner.__signature__ = signature # type: ignore[attr-defined]
|
|
1067
|
+
_inner.__granular_destructive__ = (action, level) # type: ignore[attr-defined]
|
|
1068
|
+
return _inner
|
|
1069
|
+
|
|
1070
|
+
return decorator
|
|
1071
|
+
|
|
1072
|
+
|
|
860
1073
|
def destructive_op(tool_name: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
861
1074
|
"""Wrap a top-level tool function with the version-on-mutate hook.
|
|
862
1075
|
|