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.
@@ -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
@@ -11,7 +11,7 @@ Usage:
11
11
  python src/server.py --full # Start the 377-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "4.4.2"
14
+ VERSION = "4.5.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -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 params.allow_risky_operation=true, or disable "
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 allow_risky_operation=true for this call after review, or "
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