davinci-resolve-mcp 2.208.1 → 2.209.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +64 -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 +192 -6
- package/src/utils/destructive_hook.py +315 -8
- package/src/utils/execution_lifecycle.py +26 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,70 @@
|
|
|
2
2
|
|
|
3
3
|
Release history for the DaVinci Resolve MCP Server. The latest release is summarized in the root README; older entries live here to keep the README focused.
|
|
4
4
|
|
|
5
|
+
## What's New in v2.209.1 — the test suite no longer writes to the security audit log
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **Running the suite appended fabricated events to `logs/security-audit.jsonl`.**
|
|
10
|
+
The destructive-op audit log added in v2.209.0 defaults to that path, which is
|
|
11
|
+
correct for an install and wrong for a test run: any test exercising a
|
|
12
|
+
`@destructive_op`-wrapped handler wrote a genuine-looking record.
|
|
13
|
+
`tests/test_tool_argument_validation` walks every tool, so a single run added
|
|
14
|
+
24 synthetic `delete_timelines` / `reset_all_grades` / `apply_cuts` entries,
|
|
15
|
+
and repeated runs accumulated 216.
|
|
16
|
+
|
|
17
|
+
A security log is read to establish what actually happened, so synthetic rows
|
|
18
|
+
in it are worse than a missing feature — at the point someone needs to trust
|
|
19
|
+
the file they are indistinguishable from real events. `tests/offline_guard`
|
|
20
|
+
now redirects the audit path to a temp file for the whole run, covering both
|
|
21
|
+
the pytest and `python -m unittest` entry points, and only replaces the
|
|
22
|
+
*default*: a test that configures `destructive.audit_log_path` still gets its
|
|
23
|
+
own path. A regression test asserts the active path is never inside the repo.
|
|
24
|
+
|
|
25
|
+
No released behaviour changes — the default remains `logs/security-audit.jsonl`
|
|
26
|
+
for real installs. Anyone who ran the v2.209.0 suite should expect synthetic
|
|
27
|
+
rows in their local file; they carry temp-directory `project_root` values.
|
|
28
|
+
|
|
29
|
+
## What's New in v2.209.0 — safe operations policy
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
|
|
33
|
+
- **Destructive operations now carry explicit security metadata** — wrapped
|
|
34
|
+
destructive tool calls receive an `operation_id` plus a `security` block with
|
|
35
|
+
a `risk_level` (`low`, `medium`, `high`, or `critical`) and a
|
|
36
|
+
`risk_established` flag. The existing version-on-mutate and confirm-token
|
|
37
|
+
gates stay intact, but callers now have a stable policy surface to inspect
|
|
38
|
+
and display before or after a Resolve mutation.
|
|
39
|
+
|
|
40
|
+
Levels come from the same classifier that backs pre-flight
|
|
41
|
+
`inspect_operation`, so the gate and the inspection surface cannot disagree
|
|
42
|
+
about a call. `risk_established` is false when the classifier matched no rule
|
|
43
|
+
and the level is a name-based default rather than a finding — true today for
|
|
44
|
+
80 of the 108 registered destructive actions, which is a gap to close by
|
|
45
|
+
classifying them, not by gating them.
|
|
46
|
+
- **Safe mode blocks high-risk destructive calls when enabled** —
|
|
47
|
+
`setup(action="set_defaults", params={"destructive": {"safe_mode": true}})`
|
|
48
|
+
blocks `high` and `critical` actions before the underlying Resolve handler
|
|
49
|
+
runs. Reviewed one-off calls can proceed with `allow_risky_operation=true`.
|
|
50
|
+
Unclassified actions are reported, not blocked.
|
|
51
|
+
- **Security audit JSONL for destructive calls** — allowed, blocked, and
|
|
52
|
+
pending-confirmation destructive calls write audit events to
|
|
53
|
+
`logs/security-audit.jsonl` by default. Confirmation tokens are redacted in
|
|
54
|
+
the audit payload.
|
|
55
|
+
|
|
56
|
+
### Changed
|
|
57
|
+
|
|
58
|
+
- **Risk classification is now one table, not two** — the marker and
|
|
59
|
+
clip-colour actions classify as `low` instead of falling through the name
|
|
60
|
+
heuristic as unrecognised `medium`, and `timeline.lift_range`,
|
|
61
|
+
`timeline.overwrite_range`, `timeline.apply_cuts`, `timeline.delete_track`,
|
|
62
|
+
`media_pool.delete_folders` and `graph.reset_all_grades` are now classified
|
|
63
|
+
`high` wherever risk is reported, including pre-flight inspection.
|
|
64
|
+
- **`setup` exposes destructive defaults** — `destructive.require_confirm_token`,
|
|
65
|
+
`destructive.safe_mode`, `destructive.audit_log`, and
|
|
66
|
+
`destructive.audit_log_path` are now visible through `schema`, persisted by
|
|
67
|
+
`set_defaults`, and reset by `clear_defaults`.
|
|
68
|
+
|
|
5
69
|
## What's New in v2.208.1 — #188: variant item counts come from the timeline
|
|
6
70
|
|
|
7
71
|
### Fixed
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
English | [简体中文](README.zh-CN.md)
|
|
4
4
|
|
|
5
|
-
[](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.1 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
|
|
16
16
|
|
|
17
17
|
一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
|
|
18
18
|
|
package/install.py
CHANGED
|
@@ -37,7 +37,7 @@ from src.utils.update_check import (
|
|
|
37
37
|
|
|
38
38
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
39
39
|
|
|
40
|
-
VERSION = "2.
|
|
40
|
+
VERSION = "2.209.1"
|
|
41
41
|
# Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
|
|
42
42
|
# Resolve's scripting bridge loads into newer interpreters on recent builds
|
|
43
43
|
# (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
|
package/package.json
CHANGED
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.1"
|
|
91
91
|
logger = logging.getLogger("davinci-resolve-mcp")
|
|
92
92
|
logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
|
|
93
93
|
logger.info(f"Detected platform: {get_platform()}")
|
package/src/server.py
CHANGED
|
@@ -11,7 +11,7 @@ Usage:
|
|
|
11
11
|
python src/server.py --full # Start the 353-tool granular server instead
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
|
-
VERSION = "2.
|
|
14
|
+
VERSION = "2.209.1"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -1248,7 +1248,12 @@ def _ai_governance_gate(op: str, p: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
|
1248
1248
|
def _destructive_preference_provider(key: str) -> Any:
|
|
1249
1249
|
"""Reader for C6 preferences out of the existing media-analysis prefs file."""
|
|
1250
1250
|
try:
|
|
1251
|
-
|
|
1251
|
+
prefs = _read_media_analysis_preferences()
|
|
1252
|
+
if key.startswith("destructive."):
|
|
1253
|
+
destructive = prefs.get("destructive")
|
|
1254
|
+
if isinstance(destructive, dict):
|
|
1255
|
+
return destructive.get(key.split(".", 1)[1])
|
|
1256
|
+
return prefs.get(key)
|
|
1252
1257
|
except Exception:
|
|
1253
1258
|
return None
|
|
1254
1259
|
|
|
@@ -15425,6 +15430,101 @@ def _setup_defaults_snapshot() -> Dict[str, Any]:
|
|
|
15425
15430
|
"general": _setup_general_defaults(),
|
|
15426
15431
|
"media_analysis": _setup_media_analysis_defaults(),
|
|
15427
15432
|
"updates": _setup_updates_defaults(),
|
|
15433
|
+
"destructive": _setup_destructive_defaults(),
|
|
15434
|
+
}
|
|
15435
|
+
|
|
15436
|
+
|
|
15437
|
+
def _setup_destructive_defaults() -> Dict[str, Any]:
|
|
15438
|
+
prefs = _read_media_analysis_preferences()
|
|
15439
|
+
destructive = prefs.get("destructive") if isinstance(prefs.get("destructive"), dict) else {}
|
|
15440
|
+
return {
|
|
15441
|
+
"require_confirm_token": _setup_bool(destructive.get("require_confirm_token"), True),
|
|
15442
|
+
"safe_mode": _setup_bool(destructive.get("safe_mode"), False),
|
|
15443
|
+
"audit_log": _setup_bool(destructive.get("audit_log"), True),
|
|
15444
|
+
"audit_log_path": destructive.get("audit_log_path") or os.path.join(project_dir, "logs", "security-audit.jsonl"),
|
|
15445
|
+
"preferences_path": _media_analysis_preferences_path(),
|
|
15446
|
+
}
|
|
15447
|
+
|
|
15448
|
+
|
|
15449
|
+
def _setup_set_destructive_defaults(destructive_defaults: Dict[str, Any], dry_run: bool) -> Dict[str, Any]:
|
|
15450
|
+
if not destructive_defaults:
|
|
15451
|
+
return {"changed": False, "recognized": False}
|
|
15452
|
+
|
|
15453
|
+
alias_to_key = {
|
|
15454
|
+
"require_confirm_token": "require_confirm_token",
|
|
15455
|
+
"requireconfirmtoken": "require_confirm_token",
|
|
15456
|
+
"confirm_token": "require_confirm_token",
|
|
15457
|
+
"confirmtoken": "require_confirm_token",
|
|
15458
|
+
"safe_mode": "safe_mode",
|
|
15459
|
+
"safemode": "safe_mode",
|
|
15460
|
+
"audit_log": "audit_log",
|
|
15461
|
+
"auditlog": "audit_log",
|
|
15462
|
+
"audit_log_path": "audit_log_path",
|
|
15463
|
+
"auditlogpath": "audit_log_path",
|
|
15464
|
+
}
|
|
15465
|
+
requested: Dict[str, Any] = {}
|
|
15466
|
+
for key, value in destructive_defaults.items():
|
|
15467
|
+
normalized_key = alias_to_key.get(_setup_text_key(key).replace("_", ""))
|
|
15468
|
+
if not normalized_key:
|
|
15469
|
+
normalized_key = alias_to_key.get(_setup_text_key(key))
|
|
15470
|
+
if normalized_key:
|
|
15471
|
+
requested[normalized_key] = value
|
|
15472
|
+
if not requested:
|
|
15473
|
+
return {"changed": False, "recognized": False}
|
|
15474
|
+
|
|
15475
|
+
try:
|
|
15476
|
+
preferences = _read_media_analysis_preferences_strict()
|
|
15477
|
+
except ConfigParseError as exc:
|
|
15478
|
+
return _err(f"Refusing to update destructive defaults: {exc}. The preferences file exists but is unparseable; fix or delete it to avoid wiping saved settings.")
|
|
15479
|
+
|
|
15480
|
+
before = _setup_destructive_defaults()
|
|
15481
|
+
next_preferences = dict(preferences)
|
|
15482
|
+
destructive = dict(next_preferences.get("destructive") if isinstance(next_preferences.get("destructive"), dict) else {})
|
|
15483
|
+
updates: Dict[str, Dict[str, Any]] = {}
|
|
15484
|
+
|
|
15485
|
+
def clear_requested(raw: Any) -> bool:
|
|
15486
|
+
return raw is None or (not isinstance(raw, bool) and _setup_text_key(raw) in _SETUP_CHOICE_CLEAR_VALUES)
|
|
15487
|
+
|
|
15488
|
+
for key, raw_value in requested.items():
|
|
15489
|
+
if clear_requested(raw_value):
|
|
15490
|
+
destructive.pop(key, None)
|
|
15491
|
+
updates[key] = {"before": before.get(key), "after": _setup_destructive_defaults().get(key), "cleared": True}
|
|
15492
|
+
elif key in {"require_confirm_token", "safe_mode", "audit_log"}:
|
|
15493
|
+
normalized = _setup_bool(raw_value, before.get(key, False))
|
|
15494
|
+
destructive[key] = normalized
|
|
15495
|
+
updates[key] = {"before": before.get(key), "after": normalized}
|
|
15496
|
+
elif key == "audit_log_path":
|
|
15497
|
+
path = os.path.realpath(os.path.abspath(os.path.expanduser(str(raw_value))))
|
|
15498
|
+
destructive[key] = path
|
|
15499
|
+
updates[key] = {"before": before.get(key), "after": path}
|
|
15500
|
+
|
|
15501
|
+
if dry_run:
|
|
15502
|
+
return {
|
|
15503
|
+
"changed": True,
|
|
15504
|
+
"recognized": True,
|
|
15505
|
+
"updates": updates,
|
|
15506
|
+
"before": before,
|
|
15507
|
+
"after": {**before, **{key: row.get("after") for key, row in updates.items()}},
|
|
15508
|
+
"dry_run": True,
|
|
15509
|
+
}
|
|
15510
|
+
|
|
15511
|
+
updated_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
15512
|
+
for key, row in updates.items():
|
|
15513
|
+
if not row.get("cleared"):
|
|
15514
|
+
destructive[f"{key}_updated_at"] = updated_at
|
|
15515
|
+
else:
|
|
15516
|
+
destructive.pop(f"{key}_updated_at", None)
|
|
15517
|
+
next_preferences["destructive"] = destructive
|
|
15518
|
+
_write_media_analysis_preferences(next_preferences)
|
|
15519
|
+
after = _setup_destructive_defaults()
|
|
15520
|
+
return {
|
|
15521
|
+
"changed": before != after,
|
|
15522
|
+
"recognized": True,
|
|
15523
|
+
"updates": updates,
|
|
15524
|
+
"before": before,
|
|
15525
|
+
"after": after,
|
|
15526
|
+
"updated_at": updated_at,
|
|
15527
|
+
"preferences_path": _media_analysis_preferences_path(),
|
|
15428
15528
|
}
|
|
15429
15529
|
|
|
15430
15530
|
|
|
@@ -15939,6 +16039,25 @@ def _setup_clear_defaults(keys: Any, dry_run: bool) -> Dict[str, Any]:
|
|
|
15939
16039
|
return result["media_analysis"]
|
|
15940
16040
|
result["cleared"].extend(media_clear_keys[key] for key in media_payload)
|
|
15941
16041
|
|
|
16042
|
+
destructive_clear_keys = {
|
|
16043
|
+
"require_confirm_token": "destructive.require_confirm_token",
|
|
16044
|
+
"safe_mode": "destructive.safe_mode",
|
|
16045
|
+
"audit_log": "destructive.audit_log",
|
|
16046
|
+
"audit_log_path": "destructive.audit_log_path",
|
|
16047
|
+
}
|
|
16048
|
+
destructive_payload: Dict[str, Any] = {}
|
|
16049
|
+
if clear_all or "destructive" in normalized_keys:
|
|
16050
|
+
destructive_payload = {key: "clear" for key in destructive_clear_keys}
|
|
16051
|
+
else:
|
|
16052
|
+
for key, label in destructive_clear_keys.items():
|
|
16053
|
+
if key in normalized_keys or label in normalized_keys:
|
|
16054
|
+
destructive_payload[key] = "clear"
|
|
16055
|
+
if destructive_payload:
|
|
16056
|
+
result["destructive"] = _setup_set_destructive_defaults(destructive_payload, dry_run)
|
|
16057
|
+
if result["destructive"].get("error"):
|
|
16058
|
+
return result["destructive"]
|
|
16059
|
+
result["cleared"].extend(destructive_clear_keys[key] for key in destructive_payload)
|
|
16060
|
+
|
|
15942
16061
|
if clear_all or normalized_keys & {"updates", "updates.mode", "update_mode", "mcp_update_policy"}:
|
|
15943
16062
|
result["updates"] = _setup_set_updates_defaults({"mode": "prompt"}, dry_run)
|
|
15944
16063
|
if result["updates"].get("error"):
|
|
@@ -15993,12 +16112,13 @@ def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
|
|
|
15993
16112
|
Actions:
|
|
15994
16113
|
schema() -> {defaults, actions}
|
|
15995
16114
|
get_defaults() -> {defaults}
|
|
15996
|
-
set_defaults(defaults?|media_analysis?|updates?|dry_run?) -> {defaults, changes}
|
|
16115
|
+
set_defaults(defaults?|media_analysis?|updates?|destructive?|dry_run?) -> {defaults, changes}
|
|
15997
16116
|
clear_defaults(keys?, dry_run?) -> {defaults, cleared}
|
|
15998
16117
|
|
|
15999
16118
|
Current defaults:
|
|
16000
16119
|
media_analysis.*: analysis, metadata, marker, reporting, and workflow defaults
|
|
16001
16120
|
updates.*: MCP update policy, interval, and snooze defaults
|
|
16121
|
+
destructive.*: confirm-token, safe-mode, and audit-log defaults
|
|
16002
16122
|
"""
|
|
16003
16123
|
p = _params(params)
|
|
16004
16124
|
if action in {"schema", "capabilities", "options"}:
|
|
@@ -16069,6 +16189,26 @@ def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
|
|
|
16069
16189
|
"current": _get_envelope_mode(),
|
|
16070
16190
|
"storage": _server_preferences_path(),
|
|
16071
16191
|
},
|
|
16192
|
+
"destructive.require_confirm_token": {
|
|
16193
|
+
"description": "Require one-time confirmation tokens for registered high-risk destructive actions.",
|
|
16194
|
+
"values": [True, False],
|
|
16195
|
+
"storage": _media_analysis_preferences_path(),
|
|
16196
|
+
},
|
|
16197
|
+
"destructive.safe_mode": {
|
|
16198
|
+
"description": "When enabled, safe mode blocks high/dangerous destructive actions unless allow_risky_operation=true is passed.",
|
|
16199
|
+
"values": [True, False],
|
|
16200
|
+
"storage": _media_analysis_preferences_path(),
|
|
16201
|
+
},
|
|
16202
|
+
"destructive.audit_log": {
|
|
16203
|
+
"description": "Write JSONL security audit records for destructive operations.",
|
|
16204
|
+
"values": [True, False],
|
|
16205
|
+
"storage": _media_analysis_preferences_path(),
|
|
16206
|
+
},
|
|
16207
|
+
"destructive.audit_log_path": {
|
|
16208
|
+
"description": "Absolute path for the JSONL security audit log.",
|
|
16209
|
+
"values": "absolute or expandable path",
|
|
16210
|
+
"storage": _media_analysis_preferences_path(),
|
|
16211
|
+
},
|
|
16072
16212
|
},
|
|
16073
16213
|
}
|
|
16074
16214
|
|
|
@@ -16097,6 +16237,7 @@ def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
|
|
|
16097
16237
|
for key, value in merged.items()
|
|
16098
16238
|
if key not in {"updates", "mcp_updates", "mcpUpdates", "dry_run", "dryRun"}
|
|
16099
16239
|
and key not in _general_keys
|
|
16240
|
+
and key != "destructive"
|
|
16100
16241
|
},
|
|
16101
16242
|
**({
|
|
16102
16243
|
"timed_markers_default": _first_param(
|
|
@@ -16146,6 +16287,43 @@ def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
|
|
|
16146
16287
|
)
|
|
16147
16288
|
} if any(key in merged for key in ("snooze_hours", "snoozeHours", "update_snooze_hours", "updateSnoozeHours")) else {}),
|
|
16148
16289
|
}
|
|
16290
|
+
destructive_defaults = {
|
|
16291
|
+
**_setup_nested(merged, "destructive"),
|
|
16292
|
+
**({
|
|
16293
|
+
"require_confirm_token": _first_param(
|
|
16294
|
+
merged,
|
|
16295
|
+
"require_confirm_token",
|
|
16296
|
+
"requireConfirmToken",
|
|
16297
|
+
"confirm_token",
|
|
16298
|
+
"confirmToken",
|
|
16299
|
+
default=None,
|
|
16300
|
+
)
|
|
16301
|
+
} if any(key in merged for key in ("require_confirm_token", "requireConfirmToken", "confirm_token", "confirmToken")) else {}),
|
|
16302
|
+
**({
|
|
16303
|
+
"safe_mode": _first_param(
|
|
16304
|
+
merged,
|
|
16305
|
+
"safe_mode",
|
|
16306
|
+
"safeMode",
|
|
16307
|
+
default=None,
|
|
16308
|
+
)
|
|
16309
|
+
} if any(key in merged for key in ("safe_mode", "safeMode")) else {}),
|
|
16310
|
+
**({
|
|
16311
|
+
"audit_log": _first_param(
|
|
16312
|
+
merged,
|
|
16313
|
+
"audit_log",
|
|
16314
|
+
"auditLog",
|
|
16315
|
+
default=None,
|
|
16316
|
+
)
|
|
16317
|
+
} if any(key in merged for key in ("audit_log", "auditLog")) else {}),
|
|
16318
|
+
**({
|
|
16319
|
+
"audit_log_path": _first_param(
|
|
16320
|
+
merged,
|
|
16321
|
+
"audit_log_path",
|
|
16322
|
+
"auditLogPath",
|
|
16323
|
+
default=None,
|
|
16324
|
+
)
|
|
16325
|
+
} if any(key in merged for key in ("audit_log_path", "auditLogPath")) else {}),
|
|
16326
|
+
}
|
|
16149
16327
|
|
|
16150
16328
|
general_result = _setup_set_general_defaults(general_defaults, dry_run)
|
|
16151
16329
|
if general_result.get("error"):
|
|
@@ -16156,9 +16334,16 @@ def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
|
|
|
16156
16334
|
update_result = _setup_set_updates_defaults(update_defaults, dry_run)
|
|
16157
16335
|
if update_result.get("error"):
|
|
16158
16336
|
return update_result
|
|
16159
|
-
|
|
16160
|
-
|
|
16161
|
-
|
|
16337
|
+
destructive_result = _setup_set_destructive_defaults(destructive_defaults, dry_run)
|
|
16338
|
+
if destructive_result.get("error"):
|
|
16339
|
+
return destructive_result
|
|
16340
|
+
recognized = (
|
|
16341
|
+
bool(general_result.get("recognized"))
|
|
16342
|
+
or
|
|
16343
|
+
bool(media_result.get("recognized"))
|
|
16344
|
+
or bool(update_result.get("recognized"))
|
|
16345
|
+
or bool(destructive_result.get("recognized"))
|
|
16346
|
+
)
|
|
16162
16347
|
if not recognized:
|
|
16163
16348
|
return _err("set_defaults did not receive a recognized default to set")
|
|
16164
16349
|
|
|
@@ -16168,6 +16353,7 @@ def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
|
|
|
16168
16353
|
"general": general_result,
|
|
16169
16354
|
"media_analysis": media_result,
|
|
16170
16355
|
"updates": update_result,
|
|
16356
|
+
"destructive": destructive_result,
|
|
16171
16357
|
},
|
|
16172
16358
|
defaults=_setup_defaults_snapshot(),
|
|
16173
16359
|
)
|
|
@@ -25,14 +25,26 @@ every tool that owns destructive actions is decorated.
|
|
|
25
25
|
from __future__ import annotations
|
|
26
26
|
|
|
27
27
|
import functools
|
|
28
|
+
import json
|
|
28
29
|
import logging
|
|
30
|
+
import os
|
|
31
|
+
import time
|
|
32
|
+
import uuid
|
|
29
33
|
from typing import Any, Callable, Dict, FrozenSet, Optional, Tuple
|
|
30
34
|
|
|
31
35
|
from src.utils import analysis_runs, brain_edits, media_pool_changes, timeline_versioning
|
|
36
|
+
from src.utils.execution_lifecycle import RiskAssessment, RiskLevel, classify_operation_risk
|
|
32
37
|
|
|
33
38
|
logger = logging.getLogger("resolve-mcp.destructive-hook")
|
|
34
39
|
|
|
35
40
|
|
|
41
|
+
#: Risk levels safe mode refuses. Names come from `RiskLevel`; a second
|
|
42
|
+
#: vocabulary here would let the gate and the reported level disagree.
|
|
43
|
+
SAFE_MODE_BLOCKED_RISK_LEVELS: FrozenSet[str] = frozenset({
|
|
44
|
+
RiskLevel.HIGH.value, RiskLevel.CRITICAL.value,
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
|
|
36
48
|
# ── Destructive action registry ──────────────────────────────────────────────
|
|
37
49
|
#
|
|
38
50
|
# Tool name → frozenset of action strings that mutate the working timeline (or
|
|
@@ -300,6 +312,26 @@ def is_strict_required(tool_name: str, action: str, params: Optional[Dict[str, A
|
|
|
300
312
|
return False
|
|
301
313
|
|
|
302
314
|
|
|
315
|
+
def assess_action_risk(
|
|
316
|
+
tool_name: str, action: str, params: Optional[Dict[str, Any]] = None,
|
|
317
|
+
) -> RiskAssessment:
|
|
318
|
+
"""Classify a call for the safe-operations policy.
|
|
319
|
+
|
|
320
|
+
Classification lives in `execution_lifecycle`, which the pre-flight
|
|
321
|
+
inspection surface already uses. Deriving it a second time here would let
|
|
322
|
+
`inspect_operation` and the safe-mode gate disagree about the same call —
|
|
323
|
+
the gate blocking what inspection called reversible, or the reverse.
|
|
324
|
+
"""
|
|
325
|
+
return classify_operation_risk(tool_name, action, params or {})
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def risk_level_for_action(
|
|
329
|
+
tool_name: str, action: str, params: Optional[Dict[str, Any]] = None,
|
|
330
|
+
) -> str:
|
|
331
|
+
"""Return the security risk level for a call, as a `RiskLevel` value."""
|
|
332
|
+
return assess_action_risk(tool_name, action, params).level.value
|
|
333
|
+
|
|
334
|
+
|
|
303
335
|
# ── Provider hooks ───────────────────────────────────────────────────────────
|
|
304
336
|
|
|
305
337
|
_ProjectRootProvider = Callable[[], Optional[Tuple[Any, Any, str, Optional[str]]]]
|
|
@@ -360,6 +392,163 @@ def _read_preference(key: str, default: Any = None) -> Any:
|
|
|
360
392
|
return default if value is None else value
|
|
361
393
|
|
|
362
394
|
|
|
395
|
+
def _coerce_bool(value: Any, default: bool = False) -> bool:
|
|
396
|
+
if value is None:
|
|
397
|
+
return default
|
|
398
|
+
if isinstance(value, str):
|
|
399
|
+
lowered = value.strip().lower()
|
|
400
|
+
if lowered in {"1", "true", "yes", "on"}:
|
|
401
|
+
return True
|
|
402
|
+
if lowered in {"0", "false", "no", "off"}:
|
|
403
|
+
return False
|
|
404
|
+
return default
|
|
405
|
+
return bool(value)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _safe_mode_enabled() -> bool:
|
|
409
|
+
return _coerce_bool(_read_preference("destructive.safe_mode", False), False)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _audit_enabled() -> bool:
|
|
413
|
+
return _coerce_bool(_read_preference("destructive.audit_log", True), True)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _audit_log_path() -> str:
|
|
417
|
+
configured = _read_preference("destructive.audit_log_path", None)
|
|
418
|
+
if configured:
|
|
419
|
+
return os.path.expanduser(str(configured))
|
|
420
|
+
project_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
421
|
+
return os.path.join(project_dir, "logs", "security-audit.jsonl")
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _safe_params_for_audit(params: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
|
425
|
+
if not isinstance(params, dict):
|
|
426
|
+
return None
|
|
427
|
+
redacted = dict(params)
|
|
428
|
+
for key in ("confirm_token", "confirmToken"):
|
|
429
|
+
if key in redacted:
|
|
430
|
+
redacted[key] = "<redacted>"
|
|
431
|
+
return redacted
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _audit_security_event(
|
|
435
|
+
*,
|
|
436
|
+
operation_id: str,
|
|
437
|
+
tool_name: str,
|
|
438
|
+
action: str,
|
|
439
|
+
risk_level: str,
|
|
440
|
+
status: str,
|
|
441
|
+
params: Optional[Dict[str, Any]],
|
|
442
|
+
recognised: bool = True,
|
|
443
|
+
reason: Optional[str] = None,
|
|
444
|
+
project_root: Optional[str] = None,
|
|
445
|
+
analysis_run_id: Optional[str] = None,
|
|
446
|
+
) -> None:
|
|
447
|
+
if not _audit_enabled():
|
|
448
|
+
return
|
|
449
|
+
event = {
|
|
450
|
+
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
451
|
+
"operation_id": operation_id,
|
|
452
|
+
"tool": tool_name,
|
|
453
|
+
"action": action,
|
|
454
|
+
"risk_level": risk_level,
|
|
455
|
+
"risk_established": recognised,
|
|
456
|
+
"status": status,
|
|
457
|
+
"reason": reason,
|
|
458
|
+
"analysis_run_id": analysis_run_id,
|
|
459
|
+
"project_root": project_root,
|
|
460
|
+
"params": _safe_params_for_audit(params),
|
|
461
|
+
}
|
|
462
|
+
path = _audit_log_path()
|
|
463
|
+
try:
|
|
464
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
465
|
+
with open(path, "a", encoding="utf-8") as handle:
|
|
466
|
+
handle.write(json.dumps(event, sort_keys=True, default=str) + "\n")
|
|
467
|
+
except Exception as exc:
|
|
468
|
+
logger.warning("security audit log write failed: %s", exc)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _security_block_response(
|
|
472
|
+
*,
|
|
473
|
+
operation_id: str,
|
|
474
|
+
tool_name: str,
|
|
475
|
+
action: str,
|
|
476
|
+
risk_level: str,
|
|
477
|
+
recognised: bool = True,
|
|
478
|
+
) -> Dict[str, Any]:
|
|
479
|
+
return {
|
|
480
|
+
"success": False,
|
|
481
|
+
"status": "blocked_by_security_policy",
|
|
482
|
+
"operation_id": operation_id,
|
|
483
|
+
"security": {
|
|
484
|
+
"risk_level": risk_level,
|
|
485
|
+
"risk_established": recognised,
|
|
486
|
+
"safe_mode": True,
|
|
487
|
+
"blocked": True,
|
|
488
|
+
"policy": "destructive.safe_mode",
|
|
489
|
+
},
|
|
490
|
+
"error": {
|
|
491
|
+
"message": (
|
|
492
|
+
f"Safe mode blocked {risk_level}-risk action "
|
|
493
|
+
f"'{tool_name}.{action}'. "
|
|
494
|
+
"Re-call with params.allow_risky_operation=true, or disable "
|
|
495
|
+
"destructive.safe_mode in setup defaults."
|
|
496
|
+
),
|
|
497
|
+
"code": "SAFE_MODE_BLOCKED",
|
|
498
|
+
"category": "destructive_blocked",
|
|
499
|
+
"retryable": False,
|
|
500
|
+
"remediation": (
|
|
501
|
+
"Pass allow_risky_operation=true for this call after review, or "
|
|
502
|
+
"set destructive.safe_mode=false if this session should allow "
|
|
503
|
+
"high-risk destructive actions."
|
|
504
|
+
),
|
|
505
|
+
},
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _annotate_security(
|
|
510
|
+
result: Any,
|
|
511
|
+
*,
|
|
512
|
+
operation_id: str,
|
|
513
|
+
risk_level: str,
|
|
514
|
+
blocked: bool = False,
|
|
515
|
+
recognised: bool = True,
|
|
516
|
+
) -> Any:
|
|
517
|
+
if isinstance(result, dict):
|
|
518
|
+
result.setdefault("operation_id", operation_id)
|
|
519
|
+
result.setdefault("security", {
|
|
520
|
+
"risk_level": risk_level,
|
|
521
|
+
"risk_established": recognised,
|
|
522
|
+
"safe_mode": _safe_mode_enabled(),
|
|
523
|
+
"blocked": blocked,
|
|
524
|
+
})
|
|
525
|
+
return result
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _safe_mode_allows(
|
|
529
|
+
risk_level: str,
|
|
530
|
+
params: Optional[Dict[str, Any]],
|
|
531
|
+
recognised: bool = True,
|
|
532
|
+
) -> bool:
|
|
533
|
+
"""Whether safe mode lets this call through.
|
|
534
|
+
|
|
535
|
+
Only an established HIGH or CRITICAL is blocked. Failing closed on
|
|
536
|
+
`recognised=False` was measured and rejected: 80 of the 108 registered
|
|
537
|
+
destructive actions match no risk rule today, `timeline.add_track` and
|
|
538
|
+
`timeline.duplicate` among them, so it would block three quarters of all
|
|
539
|
+
edits and teach users to leave safe mode off — weaker in practice than a
|
|
540
|
+
narrow gate they keep on. The unclassified actions are reported via
|
|
541
|
+
`risk_established` instead, and want classifying rather than blanket-gating.
|
|
542
|
+
"""
|
|
543
|
+
if not _safe_mode_enabled():
|
|
544
|
+
return True
|
|
545
|
+
if risk_level not in SAFE_MODE_BLOCKED_RISK_LEVELS:
|
|
546
|
+
return True
|
|
547
|
+
if isinstance(params, dict) and params.get("allow_risky_operation") is True:
|
|
548
|
+
return True
|
|
549
|
+
return False
|
|
550
|
+
|
|
551
|
+
|
|
363
552
|
def _resolve_versioning_context() -> Optional[Tuple[Any, Any, str, Optional[str]]]:
|
|
364
553
|
if _PROVIDER is None:
|
|
365
554
|
return None
|
|
@@ -441,6 +630,29 @@ def destructive_op(tool_name: str) -> Callable[[Callable[..., Any]], Callable[..
|
|
|
441
630
|
if not is_destructive(tool_name, action, params):
|
|
442
631
|
return fn(action, params, *args, **kwargs)
|
|
443
632
|
|
|
633
|
+
operation_id = f"op_{uuid.uuid4().hex[:12]}"
|
|
634
|
+
assessment = assess_action_risk(tool_name, action, params)
|
|
635
|
+
risk_level = assessment.level.value
|
|
636
|
+
risk_recognised = assessment.recognised
|
|
637
|
+
if not _safe_mode_allows(risk_level, params, risk_recognised):
|
|
638
|
+
_audit_security_event(
|
|
639
|
+
operation_id=operation_id,
|
|
640
|
+
tool_name=tool_name,
|
|
641
|
+
action=action,
|
|
642
|
+
risk_level=risk_level,
|
|
643
|
+
status="blocked",
|
|
644
|
+
params=params,
|
|
645
|
+
reason="safe_mode",
|
|
646
|
+
recognised=risk_recognised,
|
|
647
|
+
)
|
|
648
|
+
return _security_block_response(
|
|
649
|
+
operation_id=operation_id,
|
|
650
|
+
tool_name=tool_name,
|
|
651
|
+
action=action,
|
|
652
|
+
risk_level=risk_level,
|
|
653
|
+
recognised=risk_recognised,
|
|
654
|
+
)
|
|
655
|
+
|
|
444
656
|
# F4 — token-issuance calls don't mutate; skip the archive entirely
|
|
445
657
|
# so that token preview/cancel paths don't litter the version chain.
|
|
446
658
|
# The wrapper still annotates `_versioning` on the result so callers
|
|
@@ -453,30 +665,76 @@ def destructive_op(tool_name: str) -> Callable[[Callable[..., Any]], Callable[..
|
|
|
453
665
|
will_gate = False
|
|
454
666
|
if will_gate:
|
|
455
667
|
result = fn(action, params, *args, **kwargs)
|
|
668
|
+
_audit_security_event(
|
|
669
|
+
operation_id=operation_id,
|
|
670
|
+
tool_name=tool_name,
|
|
671
|
+
action=action,
|
|
672
|
+
risk_level=risk_level,
|
|
673
|
+
status="pending_confirmation",
|
|
674
|
+
params=params,
|
|
675
|
+
reason="confirm_token_required",
|
|
676
|
+
recognised=risk_recognised,
|
|
677
|
+
)
|
|
456
678
|
if isinstance(result, dict):
|
|
457
679
|
result.setdefault("_versioning", {
|
|
458
680
|
"analysis_run_id": None,
|
|
459
681
|
"archived": False,
|
|
460
682
|
"skipped_reason": "pending_confirm_token",
|
|
461
683
|
})
|
|
462
|
-
return
|
|
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
|