davinci-resolve-mcp 4.2.0 → 4.4.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 +130 -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 +154 -56
- package/src/granular/timeline.py +5 -2
- package/src/granular/timeline_item.py +110 -12
- package/src/server.py +36 -97
- package/src/utils/confirm_tokens.py +222 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,136 @@
|
|
|
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 v4.4.0 — 85 granular tools stop lying to clients about what they do
|
|
6
|
+
|
|
7
|
+
Granular tools infer their MCP safety annotation from the leading verb in the tool
|
|
8
|
+
name. `delete_marker` matched; `ti_delete_marker` did not, because the namespace sits
|
|
9
|
+
in front of the verb. Every `ti_*`, `timeline_*`, `graph_*` and `folder_*` tool —
|
|
10
|
+
132 of them — matched no verb rule and took the plain-write default.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **43 destructive granular tools were advertised as ordinary writes.** Deletes,
|
|
15
|
+
clears, resets, sets and loads — `ti_delete_version`, `ti_clear_flags`,
|
|
16
|
+
`timeline_delete_track`, `timeline_delete_clips`, `folder_clear_transcription`,
|
|
17
|
+
`graph_reset_all_grades` and the rest — all carried `destructiveHint=False`. A
|
|
18
|
+
client that gates on that hint, by prompting the user or refusing in a read-only
|
|
19
|
+
mode, was told every one of them was safe. v4.3.0 fixed this for `ti_copy_grades`
|
|
20
|
+
by hand; the other 42 needed the classifier fixed instead.
|
|
21
|
+
|
|
22
|
+
- **42 pure readers were advertised as writes.** Every namespaced `*_get_*` tool —
|
|
23
|
+
`ti_get_info`, `timeline_get_markers`, `graph_get_lut` — claimed it could mutate,
|
|
24
|
+
so a read-only client had to refuse work it could safely have done.
|
|
25
|
+
|
|
26
|
+
- **`detect_` was a read prefix, and `Timeline.DetectSceneCuts` adds cuts.** The one
|
|
27
|
+
tool using it, `timeline_detect_scene_cuts`, was only ever classified correctly
|
|
28
|
+
because its namespace hid it from that list — teaching the classifier to see past
|
|
29
|
+
the namespace would have promoted a tool that restructures the timeline to
|
|
30
|
+
read-only. `detect_` is gone from the read list and the tool is now explicitly
|
|
31
|
+
destructive, matching how the compound server already rates it.
|
|
32
|
+
|
|
33
|
+
- **A bare `<namespace>_<verb>` name matched nothing even after stripping.** Every
|
|
34
|
+
verb prefix ends in `_`, so `timeline_export` became `export`, which does not start
|
|
35
|
+
with `export_`. `timeline_export`, `folder_export` and `timeline_duplicate` fell
|
|
36
|
+
through. The verb probe now appends the separator before matching.
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
|
|
40
|
+
- **`tests/test_granular_tool_annotations.py` guards the classifier, not the names.**
|
|
41
|
+
Three properties, each pinning a way this failed:
|
|
42
|
+
- no tool hinted `readOnlyHint=True` calls a Resolve method outside the
|
|
43
|
+
`Get`/`Is`/`Has`/`List`/`Find`/`Export` shapes — this is what catches the next
|
|
44
|
+
`DetectSceneCuts`, and it is a property of the body, not of the name;
|
|
45
|
+
- no namespaced tool falls through to the default, checked against the verb lists
|
|
46
|
+
directly so a deliberate `WRITE` passes and a fallthrough does not;
|
|
47
|
+
- the allow-list of ruleless verbs must stay exact in both directions, so an entry
|
|
48
|
+
that later matches a verb has to be removed rather than left to rot.
|
|
49
|
+
|
|
50
|
+
### Changed
|
|
51
|
+
|
|
52
|
+
- The verb lists move to module level in `src/granular/common.py`
|
|
53
|
+
(`READ_PREFIXES`, `DESTRUCTIVE_PREFIXES`, `WRITE_PREFIXES`) alongside
|
|
54
|
+
`NAMESPACE_PREFIXES` and `matches_a_verb`, so the guards can tell a deliberate
|
|
55
|
+
write from a name that matched nothing — the distinction the old code could not
|
|
56
|
+
express, and the reason the bug was invisible.
|
|
57
|
+
|
|
58
|
+
### Validation
|
|
59
|
+
|
|
60
|
+
- Full offline suite: **3,640 passed, 1 skipped, 0 failed**, 1,269 subtests.
|
|
61
|
+
- Every one of the 387 granular tools was classified before and after. 85 changed:
|
|
62
|
+
43 write→destructive, 42 write→read. The 42 that became *less* restrictive are all
|
|
63
|
+
`*_get_*` getters, and the read-only guard above independently confirms none of
|
|
64
|
+
them calls a mutating Resolve method — that check is the evidence, not the naming.
|
|
65
|
+
- All release drift guards green. No Resolve behaviour changed: annotations are
|
|
66
|
+
metadata a client reads before calling, and no tool body was touched except
|
|
67
|
+
`timeline_detect_scene_cuts`, which gained a docstring warning and its annotation.
|
|
68
|
+
|
|
69
|
+
## What's New in v4.3.0 — the granular grade-copy stops replacing grades on clips nobody named
|
|
70
|
+
|
|
71
|
+
v4.2.0 gated the compound `timeline_item_color copy_grades`. Its granular twin,
|
|
72
|
+
`ti_copy_grades` on the `--full` server, reached the identical
|
|
73
|
+
`TimelineItem.CopyGrades` with no guard at all — and on a surface that addresses
|
|
74
|
+
clips by bare 0-based index rather than by unique ID, which made it the more
|
|
75
|
+
dangerous of the two.
|
|
76
|
+
|
|
77
|
+
### Fixed
|
|
78
|
+
|
|
79
|
+
- **`ti_copy_grades` accepted negative indices as valid targets.** The bounds check
|
|
80
|
+
was `i < len(items)`, which every negative integer passes, so `-1` reached
|
|
81
|
+
`items[-1]` and confidently graded the **last clip in the track**. An off-by-one
|
|
82
|
+
did not fail; it replaced the node graph of a clip the caller never named, and
|
|
83
|
+
`CopyGrades` leaves no version to restore. Indices are now range-checked at both
|
|
84
|
+
ends, and `bool` is refused explicitly — `True` is an `int` subclass and would
|
|
85
|
+
otherwise have indexed item 1.
|
|
86
|
+
|
|
87
|
+
- **Out-of-range indices were silently dropped.** `[i for i in indices if i < len(items)]`
|
|
88
|
+
discarded anything past the end and reported `success: true` for a copy that
|
|
89
|
+
reached fewer clips than asked for. They are now refused, with the track's real
|
|
90
|
+
item count in the response.
|
|
91
|
+
|
|
92
|
+
### Added
|
|
93
|
+
|
|
94
|
+
- **`ti_copy_grades` requires `acknowledge_trap`, then a `confirm_token`.** The same
|
|
95
|
+
two-step gate the compound action got in v4.2.0: the first call refuses with the
|
|
96
|
+
verified fact about `CopyGrades`, and the second returns a preview naming the
|
|
97
|
+
source and every resolved target — index, clip name, unique ID and start frame —
|
|
98
|
+
with a one-time token bound to those exact targets. Change the target list and the
|
|
99
|
+
token no longer matches.
|
|
100
|
+
|
|
101
|
+
**This is a breaking change for existing `ti_copy_grades` callers**, deliberately:
|
|
102
|
+
a call that used to replace grades now refuses until the caller says twice that it
|
|
103
|
+
means to. It is versioned as a minor to match v4.2.0, which made the identical
|
|
104
|
+
change to the compound action.
|
|
105
|
+
|
|
106
|
+
- **`ti_copy_grades` is now annotated `destructiveHint=True`.** Granular tools infer
|
|
107
|
+
their MCP safety hint from a name prefix, and `ti_` matches none of the read,
|
|
108
|
+
write or destructive prefix lists, so every `ti_*` tool falls through to the plain
|
|
109
|
+
write default. A client that gates on that hint was being told this tool was safe.
|
|
110
|
+
|
|
111
|
+
### Changed
|
|
112
|
+
|
|
113
|
+
- **One confirm-token implementation, in `src/utils/confirm_tokens.py`.** The
|
|
114
|
+
compound and granular servers are separate processes and each holds its own token
|
|
115
|
+
table — a token from one is not honoured by the other, which is what the
|
|
116
|
+
`CONFIRM_TOKEN_INVALID` message already said. What is now shared is the mechanism
|
|
117
|
+
and the on/off policy, rather than a second hand-rolled copy of both. `src/server.py`
|
|
118
|
+
keeps every private name it had and delegates; the error builder is injected,
|
|
119
|
+
because the granular tools return plain dicts and the compound server an envelope.
|
|
120
|
+
|
|
121
|
+
### Validation
|
|
122
|
+
|
|
123
|
+
- Full offline suite: **3,635 passed, 1 skipped, 0 failed**, 1,257 subtests — the
|
|
124
|
+
same 3,620 as v4.2.0 plus the 15 new tests, so the token extraction cost no
|
|
125
|
+
coverage. All release drift guards green.
|
|
126
|
+
- **Live on Studio 19.1.3.7**, against real `TimelineItem` objects: every refusal and
|
|
127
|
+
preview path — negative index, out-of-range index, `bool` index, empty target list,
|
|
128
|
+
source listed as its own target — plus the clip-summary reads that build the
|
|
129
|
+
preview. None of these reach `CopyGrades`, and nothing in the project was mutated.
|
|
130
|
+
- **Not validated live: the accepted-token path itself**, where a valid token is
|
|
131
|
+
redeemed and `CopyGrades` runs. That needs a disposable two-clip project and was
|
|
132
|
+
covered offline only. The call it makes is byte-for-byte the one v4.2.0 shipped;
|
|
133
|
+
what is unproven live is the redemption in front of it.
|
|
134
|
+
|
|
5
135
|
## What's New in v4.2.0 — the raw grade-copy asks before it overwrites, and an injected grade shows as graded
|
|
6
136
|
|
|
7
137
|
Contributed by [@Rohitkanithi](https://github.com/Rohitkanithi) in
|
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
|
-
> 本翻译对应 v4.
|
|
15
|
+
> 本翻译对应 v4.4.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 = "4.
|
|
40
|
+
VERSION = "4.4.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
|
@@ -28,6 +28,11 @@ from src.utils.app_control import (
|
|
|
28
28
|
restart_resolve_app,
|
|
29
29
|
)
|
|
30
30
|
from src.utils.cdl import normalize_cdl_payload
|
|
31
|
+
from src.utils.confirm_tokens import (
|
|
32
|
+
ConfirmTokenStore,
|
|
33
|
+
gate_required_from,
|
|
34
|
+
plain_error as plain_confirm_error,
|
|
35
|
+
)
|
|
31
36
|
from src.utils.cloud_operations import (
|
|
32
37
|
create_cloud_project,
|
|
33
38
|
import_cloud_project,
|
|
@@ -87,7 +92,7 @@ if not logging.getLogger().handlers:
|
|
|
87
92
|
handlers=[logging.StreamHandler()],
|
|
88
93
|
)
|
|
89
94
|
|
|
90
|
-
VERSION = "4.
|
|
95
|
+
VERSION = "4.4.0"
|
|
91
96
|
logger = logging.getLogger("davinci-resolve-mcp")
|
|
92
97
|
logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
|
|
93
98
|
logger.info(f"Detected platform: {get_platform()}")
|
|
@@ -140,65 +145,112 @@ EXTERNAL_DESTRUCTIVE_TOOL = ToolAnnotations(
|
|
|
140
145
|
)
|
|
141
146
|
|
|
142
147
|
|
|
148
|
+
#: Namespace segments that sit in FRONT of the verb in a granular tool name.
|
|
149
|
+
#:
|
|
150
|
+
#: The prefix heuristic below reads the leading verb, so a tool called
|
|
151
|
+
#: `ti_delete_marker_at_frame` matched none of the verb lists and fell through to
|
|
152
|
+
#: the plain write default — 86 tools were mis-hinted this way, 43 destructive ones
|
|
153
|
+
#: advertised as ordinary writes (a client gating on `destructiveHint` was told
|
|
154
|
+
#: `ti_copy_grades` was safe) and 43 pure readers advertised as writes. Every tool
|
|
155
|
+
#: carrying one of these is `<namespace>_<verb>_...`, so one strip exposes the verb.
|
|
156
|
+
NAMESPACE_PREFIXES = (
|
|
157
|
+
"ti_",
|
|
158
|
+
"timeline_",
|
|
159
|
+
"graph_",
|
|
160
|
+
"folder_",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _strip_namespace(name: str) -> str:
|
|
165
|
+
"""Drop one leading namespace segment so the verb heuristic can see the verb."""
|
|
166
|
+
for prefix in NAMESPACE_PREFIXES:
|
|
167
|
+
if name.startswith(prefix):
|
|
168
|
+
return name[len(prefix):]
|
|
169
|
+
return name
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _verb_probe(tool_name: str) -> str:
|
|
173
|
+
"""The stripped name, shaped so a BARE verb still matches its prefix.
|
|
174
|
+
|
|
175
|
+
Every verb prefix ends in "_", so `timeline_export` -> `export` would match
|
|
176
|
+
nothing: the tool name is exactly `<namespace>_<verb>` with no suffix. The
|
|
177
|
+
trailing "_" makes `export` match `export_` without loosening anything else.
|
|
178
|
+
"""
|
|
179
|
+
return _strip_namespace((tool_name or "").lower()) + "_"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
#: Verb prefixes, checked in this order against the name AFTER its namespace is
|
|
183
|
+
#: stripped. Module-level so `tests/test_granular_tool_annotations.py` can tell a
|
|
184
|
+
#: deliberate write from a name that matched nothing and fell through to the default.
|
|
185
|
+
READ_PREFIXES = (
|
|
186
|
+
"get_",
|
|
187
|
+
"list_",
|
|
188
|
+
"inspect_",
|
|
189
|
+
"probe_",
|
|
190
|
+
"validate_",
|
|
191
|
+
"compare_",
|
|
192
|
+
# NOT "detect_": Timeline.DetectSceneCuts adds cuts to the timeline, and the
|
|
193
|
+
# compound server rates detect_scene_cuts destructive. It only ever looked like
|
|
194
|
+
# a read because the `timeline_` namespace hid it from this list.
|
|
195
|
+
"summarize_",
|
|
196
|
+
"review_",
|
|
197
|
+
"is_",
|
|
198
|
+
"has_",
|
|
199
|
+
)
|
|
200
|
+
DESTRUCTIVE_PREFIXES = (
|
|
201
|
+
"delete_",
|
|
202
|
+
"remove_",
|
|
203
|
+
"clear_",
|
|
204
|
+
"reset_",
|
|
205
|
+
"replace_",
|
|
206
|
+
"unlink_",
|
|
207
|
+
"quit",
|
|
208
|
+
"restart",
|
|
209
|
+
"close_",
|
|
210
|
+
"stop_",
|
|
211
|
+
"overwrite_",
|
|
212
|
+
"lift_",
|
|
213
|
+
"set_",
|
|
214
|
+
"load_",
|
|
215
|
+
"switch_",
|
|
216
|
+
)
|
|
217
|
+
WRITE_PREFIXES = (
|
|
218
|
+
"add_",
|
|
219
|
+
"append_",
|
|
220
|
+
"apply_",
|
|
221
|
+
"assign_",
|
|
222
|
+
"copy_",
|
|
223
|
+
"create_",
|
|
224
|
+
"duplicate_",
|
|
225
|
+
"export_",
|
|
226
|
+
"import_",
|
|
227
|
+
"insert_",
|
|
228
|
+
"link_",
|
|
229
|
+
"move_",
|
|
230
|
+
"open_",
|
|
231
|
+
"render_",
|
|
232
|
+
"rename_",
|
|
233
|
+
"save_",
|
|
234
|
+
"start_",
|
|
235
|
+
"sync_",
|
|
236
|
+
"transcribe_",
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def matches_a_verb(tool_name: str) -> bool:
|
|
241
|
+
"""Did the name resolve to a verb rule, or fall through to the default?"""
|
|
242
|
+
return _verb_probe(tool_name).startswith(
|
|
243
|
+
READ_PREFIXES + DESTRUCTIVE_PREFIXES + WRITE_PREFIXES)
|
|
244
|
+
|
|
245
|
+
|
|
143
246
|
def _annotations_for_tool_name(tool_name: str) -> ToolAnnotations:
|
|
144
247
|
"""Infer conservative MCP client-safety hints for legacy granular tools."""
|
|
145
|
-
name = (tool_name
|
|
146
|
-
|
|
147
|
-
"get_",
|
|
148
|
-
"list_",
|
|
149
|
-
"inspect_",
|
|
150
|
-
"probe_",
|
|
151
|
-
"validate_",
|
|
152
|
-
"compare_",
|
|
153
|
-
"detect_",
|
|
154
|
-
"summarize_",
|
|
155
|
-
"review_",
|
|
156
|
-
"is_",
|
|
157
|
-
"has_",
|
|
158
|
-
)
|
|
159
|
-
destructive_prefixes = (
|
|
160
|
-
"delete_",
|
|
161
|
-
"remove_",
|
|
162
|
-
"clear_",
|
|
163
|
-
"reset_",
|
|
164
|
-
"replace_",
|
|
165
|
-
"unlink_",
|
|
166
|
-
"quit",
|
|
167
|
-
"restart",
|
|
168
|
-
"close_",
|
|
169
|
-
"stop_",
|
|
170
|
-
"overwrite_",
|
|
171
|
-
"lift_",
|
|
172
|
-
"set_",
|
|
173
|
-
"load_",
|
|
174
|
-
"switch_",
|
|
175
|
-
)
|
|
176
|
-
write_prefixes = (
|
|
177
|
-
"add_",
|
|
178
|
-
"append_",
|
|
179
|
-
"apply_",
|
|
180
|
-
"assign_",
|
|
181
|
-
"copy_",
|
|
182
|
-
"create_",
|
|
183
|
-
"duplicate_",
|
|
184
|
-
"export_",
|
|
185
|
-
"import_",
|
|
186
|
-
"insert_",
|
|
187
|
-
"link_",
|
|
188
|
-
"move_",
|
|
189
|
-
"open_",
|
|
190
|
-
"render_",
|
|
191
|
-
"rename_",
|
|
192
|
-
"save_",
|
|
193
|
-
"start_",
|
|
194
|
-
"sync_",
|
|
195
|
-
"transcribe_",
|
|
196
|
-
)
|
|
197
|
-
if name.startswith(read_prefixes):
|
|
248
|
+
name = _verb_probe(tool_name)
|
|
249
|
+
if name.startswith(READ_PREFIXES):
|
|
198
250
|
return READ_ONLY_TOOL
|
|
199
|
-
if name.startswith(
|
|
251
|
+
if name.startswith(DESTRUCTIVE_PREFIXES):
|
|
200
252
|
return DESTRUCTIVE_TOOL
|
|
201
|
-
if name.startswith(
|
|
253
|
+
if name.startswith(WRITE_PREFIXES):
|
|
202
254
|
return WRITE_TOOL
|
|
203
255
|
return WRITE_TOOL
|
|
204
256
|
|
|
@@ -797,4 +849,50 @@ def _ai_result_payload(returned):
|
|
|
797
849
|
payload["error"] = message
|
|
798
850
|
return payload
|
|
799
851
|
|
|
852
|
+
|
|
853
|
+
# ── Confirmation gate ────────────────────────────────────────────────────────
|
|
854
|
+
#
|
|
855
|
+
# The granular server is a separate process from the compound one, so it holds its
|
|
856
|
+
# own token table; a token minted here is not honoured there and vice versa. What
|
|
857
|
+
# is shared is the implementation and the on/off policy, from
|
|
858
|
+
# src/utils/confirm_tokens.py — the granular tools return plain dicts rather than
|
|
859
|
+
# the compound envelope, so the error builder is the plain one.
|
|
860
|
+
|
|
861
|
+
_MEDIA_ANALYSIS_PREFS_ENV = "DAVINCI_RESOLVE_MCP_MEDIA_ANALYSIS_PREFS"
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def _media_analysis_preferences():
|
|
865
|
+
"""Read the same preferences file the compound server and setup write."""
|
|
866
|
+
import json
|
|
867
|
+
|
|
868
|
+
override = os.environ.get(_MEDIA_ANALYSIS_PREFS_ENV)
|
|
869
|
+
if override:
|
|
870
|
+
path = os.path.realpath(os.path.abspath(os.path.expanduser(override)))
|
|
871
|
+
else:
|
|
872
|
+
path = os.path.join(PROJECT_DIR, "logs", "media-analysis-preferences.json")
|
|
873
|
+
try:
|
|
874
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
875
|
+
payload = json.load(handle)
|
|
876
|
+
return payload if isinstance(payload, dict) else {}
|
|
877
|
+
except (OSError, ValueError):
|
|
878
|
+
return {}
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def _confirm_token_required() -> bool:
|
|
882
|
+
"""Honor the setup default destructive.require_confirm_token (default True)."""
|
|
883
|
+
try:
|
|
884
|
+
prefs = _media_analysis_preferences()
|
|
885
|
+
except Exception:
|
|
886
|
+
prefs = {}
|
|
887
|
+
return gate_required_from(prefs)
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
CONFIRM_TOKENS = ConfirmTokenStore(
|
|
891
|
+
err=plain_confirm_error,
|
|
892
|
+
# Resolved per call so the preference can be changed without a server restart,
|
|
893
|
+
# and so tests can patch the module-level function.
|
|
894
|
+
required=lambda: _confirm_token_required(),
|
|
895
|
+
)
|
|
896
|
+
|
|
897
|
+
|
|
800
898
|
__all__ = [name for name in globals() if not name.startswith("__")]
|
package/src/granular/timeline.py
CHANGED
|
@@ -878,9 +878,12 @@ def timeline_create_subtitles_from_audio(
|
|
|
878
878
|
return {"success": bool(result)}
|
|
879
879
|
|
|
880
880
|
|
|
881
|
-
|
|
881
|
+
# Explicit: the verb heuristic has no rule for "detect", and this one restructures
|
|
882
|
+
# the timeline by adding cuts. The compound server rates timeline_ai.detect_scene_cuts
|
|
883
|
+
# destructive; this is the same Resolve call, so it carries the same hint.
|
|
884
|
+
@mcp.tool(annotations=DESTRUCTIVE_TOOL)
|
|
882
885
|
def timeline_detect_scene_cuts() -> Dict[str, Any]:
|
|
883
|
-
"""Detect scene cuts in the current timeline."""
|
|
886
|
+
"""Detect scene cuts in the current timeline. DESTRUCTIVE — adds cuts to the timeline."""
|
|
884
887
|
_, tl, err = _get_timeline()
|
|
885
888
|
if err:
|
|
886
889
|
return err
|
|
@@ -24,6 +24,28 @@ def _has_audio_type(item):
|
|
|
24
24
|
or _item_type(item, "GetMediaType") == "audio")
|
|
25
25
|
|
|
26
26
|
|
|
27
|
+
def _copy_grade_item_summary(item, index):
|
|
28
|
+
"""Name a clip well enough that a caller can recognise it in a confirmation.
|
|
29
|
+
|
|
30
|
+
Every read is guarded: Resolve fabricates a callable for ANY attribute name on
|
|
31
|
+
its objects, so `getattr(item, "Whatever")` is never absent and a bad call
|
|
32
|
+
raises rather than returning None. Absent detail degrades the preview; it must
|
|
33
|
+
not break the gate that the preview exists to serve.
|
|
34
|
+
"""
|
|
35
|
+
summary = {"index": index}
|
|
36
|
+
for key, method in (("name", "GetName"), ("id", "GetUniqueId"), ("start", "GetStart")):
|
|
37
|
+
getter = getattr(item, method, None)
|
|
38
|
+
if not callable(getter):
|
|
39
|
+
continue
|
|
40
|
+
try:
|
|
41
|
+
value = getter()
|
|
42
|
+
except Exception:
|
|
43
|
+
continue
|
|
44
|
+
if value is not None:
|
|
45
|
+
summary[key] = value
|
|
46
|
+
return summary
|
|
47
|
+
|
|
48
|
+
|
|
27
49
|
@mcp.resource("resolve://timeline-item/{timeline_item_id}")
|
|
28
50
|
def get_timeline_item_properties(timeline_item_id: str) -> Dict[str, Any]:
|
|
29
51
|
"""Get properties of a specific timeline item by ID.
|
|
@@ -1891,30 +1913,106 @@ def ti_finalize_take(item_index: int = 0, track_type: str = "video", track_index
|
|
|
1891
1913
|
return {"success": bool(item.FinalizeTake())}
|
|
1892
1914
|
|
|
1893
1915
|
|
|
1894
|
-
@mcp.tool()
|
|
1895
|
-
def ti_copy_grades(
|
|
1896
|
-
|
|
1916
|
+
@mcp.tool(annotations=DESTRUCTIVE_TOOL)
|
|
1917
|
+
def ti_copy_grades(
|
|
1918
|
+
target_item_indices: List[int],
|
|
1919
|
+
track_type: str = "video",
|
|
1920
|
+
track_index: int = 1,
|
|
1921
|
+
source_item_index: int = 0,
|
|
1922
|
+
acknowledge_trap: bool = False,
|
|
1923
|
+
confirm_token: Optional[str] = None,
|
|
1924
|
+
) -> Dict[str, Any]:
|
|
1925
|
+
"""Copy grades from one timeline item to others. DESTRUCTIVE — gated.
|
|
1926
|
+
|
|
1927
|
+
`TimelineItem.CopyGrades` replaces each target's ENTIRE node graph with the
|
|
1928
|
+
source's and creates no version to go back to, so a target's hand grade is gone
|
|
1929
|
+
with no way to recover it. Two acknowledgements are required, in order:
|
|
1930
|
+
`acknowledge_trap=true` (you know what the API does), then `confirm_token` from
|
|
1931
|
+
the preview this returns (you have seen which clips it resolved).
|
|
1897
1932
|
|
|
1898
1933
|
Args:
|
|
1899
1934
|
target_item_indices: List of 0-based indices of target items.
|
|
1900
1935
|
track_type: 'video' or 'audio'. Default: 'video'.
|
|
1901
1936
|
track_index: 1-based track index. Default: 1.
|
|
1902
1937
|
source_item_index: 0-based source item index. Default: 0.
|
|
1938
|
+
acknowledge_trap: Must be true — confirms you accept that target grades are
|
|
1939
|
+
replaced unrecoverably.
|
|
1940
|
+
confirm_token: Token from this tool's own confirmation_required response.
|
|
1903
1941
|
"""
|
|
1904
1942
|
_, tl, err = _get_timeline()
|
|
1905
1943
|
if err:
|
|
1906
1944
|
return err
|
|
1907
|
-
items = tl.GetItemListInTrack(track_type, track_index)
|
|
1945
|
+
items = tl.GetItemListInTrack(track_type, track_index) or []
|
|
1908
1946
|
if not items:
|
|
1909
1947
|
return {"error": "No items in track"}
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1948
|
+
if not isinstance(target_item_indices, list) or not target_item_indices:
|
|
1949
|
+
return {"error": "target_item_indices must be a non-empty list of 0-based indices"}
|
|
1950
|
+
|
|
1951
|
+
# A negative index is a real Python index: `items[-1]` silently grades the LAST
|
|
1952
|
+
# clip in the track. The old bounds check (`i < len(items)`) let every negative
|
|
1953
|
+
# through, so an off-by-one produced a confident success on the wrong clip.
|
|
1954
|
+
out_of_range = sorted({i for i in target_item_indices
|
|
1955
|
+
if not isinstance(i, int) or isinstance(i, bool)
|
|
1956
|
+
or i < 0 or i >= len(items)})
|
|
1957
|
+
if out_of_range:
|
|
1958
|
+
return {"error": f"target_item_indices out of range for {len(items)} items in "
|
|
1959
|
+
f"{track_type} track {track_index}: {out_of_range}",
|
|
1960
|
+
"track_item_count": len(items)}
|
|
1961
|
+
if not isinstance(source_item_index, int) or isinstance(source_item_index, bool) \
|
|
1962
|
+
or source_item_index < 0 or source_item_index >= len(items):
|
|
1963
|
+
return {"error": f"source_item_index {source_item_index} out of range for "
|
|
1964
|
+
f"{len(items)} items in {track_type} track {track_index}",
|
|
1965
|
+
"track_item_count": len(items)}
|
|
1966
|
+
|
|
1967
|
+
source = items[source_item_index]
|
|
1968
|
+
# De-duplicate while keeping caller order: grading one clip twice is never what
|
|
1969
|
+
# was meant, and it would double-count the preview the caller confirms against.
|
|
1970
|
+
seen = set()
|
|
1971
|
+
ordered = [i for i in target_item_indices if not (i in seen or seen.add(i))]
|
|
1972
|
+
if source_item_index in seen:
|
|
1973
|
+
return {"error": "source_item_index is also listed in target_item_indices; "
|
|
1974
|
+
"copying a grade onto its own source is a no-op that would "
|
|
1975
|
+
"still consume a confirmation"}
|
|
1976
|
+
targets = [items[i] for i in ordered]
|
|
1977
|
+
|
|
1978
|
+
gate_params = {
|
|
1979
|
+
"target_item_indices": ordered,
|
|
1980
|
+
"track_type": track_type,
|
|
1981
|
+
"track_index": track_index,
|
|
1982
|
+
"source_item_index": source_item_index,
|
|
1983
|
+
}
|
|
1984
|
+
if not acknowledge_trap:
|
|
1985
|
+
return {
|
|
1986
|
+
"success": False,
|
|
1987
|
+
"error": "'ti_copy_grades' is refused: TimelineItem.CopyGrades replaces "
|
|
1988
|
+
"each target's entire node graph and leaves no version to "
|
|
1989
|
+
"restore. Re-send with acknowledge_trap=true if that is "
|
|
1990
|
+
"genuinely what you want.",
|
|
1991
|
+
"known_limitation": [
|
|
1992
|
+
"TimelineItem.CopyGrades replaces the target grade wholesale and "
|
|
1993
|
+
"creates no recovery version (measured on Studio 19.1.3.7; "
|
|
1994
|
+
"reconfirmed on Studio 21.1.0.14, issue #207)."
|
|
1995
|
+
],
|
|
1996
|
+
"retry_with": {"acknowledge_trap": True},
|
|
1997
|
+
}
|
|
1998
|
+
if confirm_token is None and CONFIRM_TOKENS.required():
|
|
1999
|
+
return CONFIRM_TOKENS.issue(
|
|
2000
|
+
action="ti_copy_grades",
|
|
2001
|
+
params=gate_params,
|
|
2002
|
+
preview={
|
|
2003
|
+
"operation": "ti_copy_grades",
|
|
2004
|
+
"warning": "Replaces the entire node graph on every target item.",
|
|
2005
|
+
"source": _copy_grade_item_summary(items[source_item_index], source_item_index),
|
|
2006
|
+
"target_count": len(targets),
|
|
2007
|
+
"targets": [_copy_grade_item_summary(items[i], i) for i in ordered],
|
|
2008
|
+
},
|
|
2009
|
+
)
|
|
2010
|
+
blocked = CONFIRM_TOKENS.consume(action="ti_copy_grades", params={
|
|
2011
|
+
**gate_params, "confirm_token": confirm_token})
|
|
2012
|
+
if blocked:
|
|
2013
|
+
return blocked
|
|
2014
|
+
return {"success": bool(source.CopyGrades(targets)),
|
|
2015
|
+
"target_count": len(targets)}
|
|
1918
2016
|
|
|
1919
2017
|
|
|
1920
2018
|
@mcp.tool()
|
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.
|
|
14
|
+
VERSION = "4.4.0"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -73,6 +73,10 @@ from src.utils.readback import verify_by_readback, verification_stats as _verifi
|
|
|
73
73
|
from src.utils import operation_result as _operation_result
|
|
74
74
|
from src.utils import operation_log as _operation_log
|
|
75
75
|
from src.utils.bool_params import explicit_bool_param as _explicit_bool_param
|
|
76
|
+
from src.utils.confirm_tokens import (
|
|
77
|
+
ConfirmTokenStore as _ConfirmTokenStore,
|
|
78
|
+
gate_required_from as _gate_required_from,
|
|
79
|
+
)
|
|
76
80
|
from src.utils.operation_result import (
|
|
77
81
|
build_operation_envelope as _build_operation_envelope,
|
|
78
82
|
get_envelope_mode as _get_envelope_mode,
|
|
@@ -1838,78 +1842,47 @@ import time as _time
|
|
|
1838
1842
|
import uuid as _uuid
|
|
1839
1843
|
|
|
1840
1844
|
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1845
|
+
def _confirm_token_required() -> bool:
|
|
1846
|
+
"""Honor setup default destructive.require_confirm_token (default True)."""
|
|
1847
|
+
try:
|
|
1848
|
+
prefs = _read_media_analysis_preferences() if "_read_media_analysis_preferences" in globals() else {}
|
|
1849
|
+
except Exception:
|
|
1850
|
+
prefs = {}
|
|
1851
|
+
return _gate_required_from(prefs)
|
|
1852
|
+
|
|
1853
|
+
|
|
1854
|
+
#: The token machinery itself lives in src/utils/confirm_tokens.py so the granular
|
|
1855
|
+
#: server can run the same gate in its own process — both surfaces reach
|
|
1856
|
+
#: TimelineItem.CopyGrades, and a second hand-rolled copy would drift. The names
|
|
1857
|
+
#: below stay module-level because callers and tests reach for them directly.
|
|
1858
|
+
#:
|
|
1859
|
+
#: `required` is a lambda, not `_confirm_token_required` itself, so the global is
|
|
1860
|
+
#: resolved on every call: tests patch `_confirm_token_required` on this module, and
|
|
1861
|
+
#: binding the function object here would capture the original and make that patch
|
|
1862
|
+
#: invisible to the store.
|
|
1863
|
+
_CONFIRM_TOKEN_STORE = _ConfirmTokenStore(
|
|
1864
|
+
err=_err,
|
|
1865
|
+
required=lambda: _confirm_token_required(),
|
|
1866
|
+
ttl_seconds=300,
|
|
1867
|
+
)
|
|
1868
|
+
_CONFIRM_TOKENS: Dict[str, Dict[str, Any]] = _CONFIRM_TOKEN_STORE.tokens
|
|
1869
|
+
_CONFIRM_TOKENS_LOCK = _CONFIRM_TOKEN_STORE.lock
|
|
1870
|
+
_CONFIRM_TTL_SECONDS = _CONFIRM_TOKEN_STORE.ttl_seconds
|
|
1847
1871
|
|
|
1848
1872
|
|
|
1849
1873
|
def _confirm_token_fingerprint(action: str, params: Optional[Dict[str, Any]]) -> str:
|
|
1850
1874
|
"""Stable hash of (action, params) that identifies one specific mutation request."""
|
|
1851
|
-
|
|
1852
|
-
# Strip the confirm_token itself if the caller is echoing it back to us.
|
|
1853
|
-
if isinstance(payload["params"], dict) and "confirm_token" in payload["params"]:
|
|
1854
|
-
payload["params"] = {k: v for k, v in payload["params"].items() if k != "confirm_token"}
|
|
1855
|
-
try:
|
|
1856
|
-
blob = json.dumps(payload, sort_keys=True, default=str)
|
|
1857
|
-
except Exception:
|
|
1858
|
-
blob = repr(payload)
|
|
1859
|
-
return _hashlib.sha256(blob.encode("utf-8")).hexdigest()[:16]
|
|
1875
|
+
return _CONFIRM_TOKEN_STORE.fingerprint(action, params)
|
|
1860
1876
|
|
|
1861
1877
|
|
|
1862
1878
|
def _confirm_token_gc():
|
|
1863
|
-
"""Drop expired tokens; called on every issue/validate.
|
|
1864
|
-
|
|
1865
|
-
now = _time.time()
|
|
1866
|
-
with _CONFIRM_TOKENS_LOCK:
|
|
1867
|
-
expired = [t for t, rec in _CONFIRM_TOKENS.items() if rec.get("expires_at", 0) < now]
|
|
1868
|
-
for t in expired:
|
|
1869
|
-
_CONFIRM_TOKENS.pop(t, None)
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
def _confirm_token_required() -> bool:
|
|
1873
|
-
"""Honor setup default destructive.require_confirm_token (default True)."""
|
|
1874
|
-
try:
|
|
1875
|
-
prefs = _read_media_analysis_preferences() if "_read_media_analysis_preferences" in globals() else {}
|
|
1876
|
-
except Exception:
|
|
1877
|
-
prefs = {}
|
|
1878
|
-
destructive = prefs.get("destructive") if isinstance(prefs.get("destructive"), dict) else {}
|
|
1879
|
-
val = destructive.get("require_confirm_token", True)
|
|
1880
|
-
if isinstance(val, str):
|
|
1881
|
-
return val.strip().lower() not in {"0", "false", "no", "off"}
|
|
1882
|
-
return bool(val)
|
|
1879
|
+
"""Drop expired tokens; called on every issue/validate."""
|
|
1880
|
+
_CONFIRM_TOKEN_STORE.gc()
|
|
1883
1881
|
|
|
1884
1882
|
|
|
1885
1883
|
def _issue_confirm_token(*, action: str, params: Optional[Dict[str, Any]], preview: Dict[str, Any]) -> Dict[str, Any]:
|
|
1886
1884
|
"""Mint a token. Returns the pending_user_decision response shape."""
|
|
1887
|
-
|
|
1888
|
-
fp = _confirm_token_fingerprint(action, params)
|
|
1889
|
-
expires_at = _time.time() + _CONFIRM_TTL_SECONDS
|
|
1890
|
-
with _CONFIRM_TOKENS_LOCK:
|
|
1891
|
-
_confirm_token_gc()
|
|
1892
|
-
_CONFIRM_TOKENS[token] = {
|
|
1893
|
-
"action": action,
|
|
1894
|
-
"fingerprint": fp,
|
|
1895
|
-
"expires_at": expires_at,
|
|
1896
|
-
"issued_at": _time.time(),
|
|
1897
|
-
}
|
|
1898
|
-
body = _err(
|
|
1899
|
-
f"This action is destructive. Re-call with confirm_token to proceed.",
|
|
1900
|
-
code="CONFIRMATION_REQUIRED",
|
|
1901
|
-
category="pending_user_decision",
|
|
1902
|
-
retryable=False,
|
|
1903
|
-
remediation=f"Re-call {action} with params.confirm_token={token!r}; token expires in {_CONFIRM_TTL_SECONDS}s.",
|
|
1904
|
-
)
|
|
1905
|
-
body.update({
|
|
1906
|
-
"status": "confirmation_required",
|
|
1907
|
-
"confirm_token": token,
|
|
1908
|
-
"preview": preview,
|
|
1909
|
-
"expires_at_epoch": expires_at,
|
|
1910
|
-
"ttl_seconds": _CONFIRM_TTL_SECONDS,
|
|
1911
|
-
})
|
|
1912
|
-
return body
|
|
1885
|
+
return _CONFIRM_TOKEN_STORE.issue(action=action, params=params, preview=preview)
|
|
1913
1886
|
|
|
1914
1887
|
|
|
1915
1888
|
def _consume_confirm_token(*, action: str, params: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
|
@@ -1917,41 +1890,7 @@ def _consume_confirm_token(*, action: str, params: Optional[Dict[str, Any]]) ->
|
|
|
1917
1890
|
If missing/expired/mismatched, return a destructive_blocked error.
|
|
1918
1891
|
If gating is disabled, return None (proceed).
|
|
1919
1892
|
"""
|
|
1920
|
-
|
|
1921
|
-
return None
|
|
1922
|
-
token = (params or {}).get("confirm_token") or (params or {}).get("confirmToken")
|
|
1923
|
-
if not token:
|
|
1924
|
-
return None # Caller is expected to call _issue_confirm_token in this case.
|
|
1925
|
-
with _CONFIRM_TOKENS_LOCK:
|
|
1926
|
-
_confirm_token_gc()
|
|
1927
|
-
rec = _CONFIRM_TOKENS.pop(token, None) # one-time use, atomic with gc
|
|
1928
|
-
if rec is None:
|
|
1929
|
-
return _err(
|
|
1930
|
-
"confirm_token is invalid, expired, or was issued by a different "
|
|
1931
|
-
"server instance (tokens are valid only on the instance that "
|
|
1932
|
-
"issued them — e.g. a stdio-server token is not honored by the "
|
|
1933
|
-
"networked server).",
|
|
1934
|
-
code="CONFIRM_TOKEN_INVALID",
|
|
1935
|
-
category="destructive_blocked",
|
|
1936
|
-
retryable=False,
|
|
1937
|
-
remediation=f"Re-call {action} without confirm_token on this instance to receive a fresh token.",
|
|
1938
|
-
)
|
|
1939
|
-
if rec.get("action") != action:
|
|
1940
|
-
return _err(
|
|
1941
|
-
f"confirm_token issued for {rec.get('action')!r}, not {action!r}",
|
|
1942
|
-
code="CONFIRM_TOKEN_ACTION_MISMATCH",
|
|
1943
|
-
category="destructive_blocked",
|
|
1944
|
-
retryable=False,
|
|
1945
|
-
)
|
|
1946
|
-
if rec.get("fingerprint") != _confirm_token_fingerprint(action, params):
|
|
1947
|
-
return _err(
|
|
1948
|
-
"confirm_token does not match the current params",
|
|
1949
|
-
code="CONFIRM_TOKEN_FINGERPRINT_MISMATCH",
|
|
1950
|
-
category="destructive_blocked",
|
|
1951
|
-
retryable=False,
|
|
1952
|
-
remediation="Either re-issue the token with current params or roll back the params change.",
|
|
1953
|
-
)
|
|
1954
|
-
return None # OK to proceed
|
|
1893
|
+
return _CONFIRM_TOKEN_STORE.consume(action=action, params=params)
|
|
1955
1894
|
|
|
1956
1895
|
|
|
1957
1896
|
def _activate_resolve_window() -> Dict[str, Any]:
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""One confirm-token implementation, shared by the compound and granular servers.
|
|
2
|
+
|
|
3
|
+
A confirm token is the last barrier in front of a mutation that cannot be undone:
|
|
4
|
+
the first call mints a token and returns a preview *instead of acting*, and only a
|
|
5
|
+
second call carrying that token is allowed through. Tokens are short-lived,
|
|
6
|
+
single-use, bound to one action name, and bound to a fingerprint of the params, so
|
|
7
|
+
a token issued for one target set is refused when the targets change.
|
|
8
|
+
|
|
9
|
+
Tokens live in the process that issued them. The compound server and the granular
|
|
10
|
+
(`--full`) server are separate processes and therefore hold separate stores; a
|
|
11
|
+
token from one is not honoured by the other, which is what the CONFIRM_TOKEN_INVALID
|
|
12
|
+
message says out loud. Sharing this module shares the *implementation*, never the
|
|
13
|
+
state.
|
|
14
|
+
|
|
15
|
+
That distinction is the reason this file exists. Both servers reach
|
|
16
|
+
`TimelineItem.CopyGrades`, which replaces a target's whole node graph and leaves no
|
|
17
|
+
version to go back to, so both need the same gate — and a second hand-rolled copy of
|
|
18
|
+
it would drift from this one exactly the way seventeen copies of the live-harness
|
|
19
|
+
stub installer drifted before v4.1.3. The error builder differs between the two
|
|
20
|
+
surfaces (the compound server has a structured envelope, the granular server returns
|
|
21
|
+
plain dicts), so it is injected rather than assumed.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import hashlib
|
|
27
|
+
import json
|
|
28
|
+
import threading
|
|
29
|
+
import time
|
|
30
|
+
import uuid
|
|
31
|
+
from typing import Any, Callable, Dict, Optional
|
|
32
|
+
|
|
33
|
+
#: Long enough for a human to read a preview and decide, short enough that a token
|
|
34
|
+
#: left lying around in a transcript is not a standing authorisation.
|
|
35
|
+
DEFAULT_TTL_SECONDS = 300
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
#: Preference key that switches the gate off, and the file both servers read it from.
|
|
39
|
+
PREFERENCE_KEY = "require_confirm_token"
|
|
40
|
+
PREFERENCE_SECTION = "destructive"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def gate_required_from(preferences: Optional[Dict[str, Any]]) -> bool:
|
|
44
|
+
"""Read `destructive.require_confirm_token` out of a preferences payload.
|
|
45
|
+
|
|
46
|
+
The policy — default on, and the exact set of strings that count as off — is
|
|
47
|
+
shared even though each server reads the preferences file for itself, because
|
|
48
|
+
the default is the part that must never drift. A surface that defaulted this to
|
|
49
|
+
False would silently have no gate at all while still looking gated in code.
|
|
50
|
+
"""
|
|
51
|
+
if not isinstance(preferences, dict):
|
|
52
|
+
return True
|
|
53
|
+
section = preferences.get(PREFERENCE_SECTION)
|
|
54
|
+
if not isinstance(section, dict):
|
|
55
|
+
return True
|
|
56
|
+
value = section.get(PREFERENCE_KEY, True)
|
|
57
|
+
if isinstance(value, str):
|
|
58
|
+
return value.strip().lower() not in {"0", "false", "no", "off"}
|
|
59
|
+
return bool(value)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def plain_error(message: str, **fields: Any) -> Dict[str, Any]:
|
|
63
|
+
"""Error builder for surfaces with no structured envelope (the granular server).
|
|
64
|
+
|
|
65
|
+
Keeps the diagnostic fields the confirm flow relies on — `code` above all, since
|
|
66
|
+
that is what a caller branches on — without inventing an envelope the granular
|
|
67
|
+
tools do not otherwise emit.
|
|
68
|
+
"""
|
|
69
|
+
body: Dict[str, Any] = {"success": False, "error": message}
|
|
70
|
+
for key, value in fields.items():
|
|
71
|
+
if value is not None:
|
|
72
|
+
body[key] = value
|
|
73
|
+
return body
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ConfirmTokenStore:
|
|
77
|
+
"""Mint, hold and redeem one-time confirmation tokens.
|
|
78
|
+
|
|
79
|
+
`required` and `err` are callables rather than values so that a caller can swap
|
|
80
|
+
either at runtime: the compound server's preference lookup is patched by tests
|
|
81
|
+
on the module object, and resolving it per call is what makes that patch visible
|
|
82
|
+
here instead of being captured once at construction.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
*,
|
|
88
|
+
err: Callable[..., Dict[str, Any]],
|
|
89
|
+
required: Optional[Callable[[], bool]] = None,
|
|
90
|
+
ttl_seconds: int = DEFAULT_TTL_SECONDS,
|
|
91
|
+
) -> None:
|
|
92
|
+
self._err = err
|
|
93
|
+
self._required = required if required is not None else (lambda: True)
|
|
94
|
+
self.ttl_seconds = ttl_seconds
|
|
95
|
+
self.tokens: Dict[str, Dict[str, Any]] = {}
|
|
96
|
+
# The control panel runs on a threaded HTTP server, so issue/consume/gc can
|
|
97
|
+
# run on concurrent threads. Guard every access so a GC pass cannot race a
|
|
98
|
+
# write and so validate-then-pop stays atomic.
|
|
99
|
+
self.lock = threading.RLock()
|
|
100
|
+
|
|
101
|
+
# ── internals ────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
def required(self) -> bool:
|
|
104
|
+
"""Is the gate switched on right now?"""
|
|
105
|
+
return bool(self._required())
|
|
106
|
+
|
|
107
|
+
def fingerprint(self, action: str, params: Optional[Dict[str, Any]]) -> str:
|
|
108
|
+
"""Stable hash of (action, params) identifying one specific mutation request."""
|
|
109
|
+
payload = {"action": action, "params": params or {}}
|
|
110
|
+
# Strip the token itself if the caller is echoing it back to us, so the
|
|
111
|
+
# fingerprint of "the request" is the same before and after issuance.
|
|
112
|
+
if isinstance(payload["params"], dict) and "confirm_token" in payload["params"]:
|
|
113
|
+
payload["params"] = {
|
|
114
|
+
k: v for k, v in payload["params"].items() if k != "confirm_token"
|
|
115
|
+
}
|
|
116
|
+
try:
|
|
117
|
+
blob = json.dumps(payload, sort_keys=True, default=str)
|
|
118
|
+
except Exception:
|
|
119
|
+
blob = repr(payload)
|
|
120
|
+
return hashlib.sha256(blob.encode("utf-8")).hexdigest()[:16]
|
|
121
|
+
|
|
122
|
+
def gc(self) -> None:
|
|
123
|
+
"""Drop expired tokens. Callers may already hold the lock; RLock re-entry is safe."""
|
|
124
|
+
now = time.time()
|
|
125
|
+
with self.lock:
|
|
126
|
+
expired = [t for t, rec in self.tokens.items() if rec.get("expires_at", 0) < now]
|
|
127
|
+
for token in expired:
|
|
128
|
+
self.tokens.pop(token, None)
|
|
129
|
+
|
|
130
|
+
# ── the gate ─────────────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
def issue(
|
|
133
|
+
self,
|
|
134
|
+
*,
|
|
135
|
+
action: str,
|
|
136
|
+
params: Optional[Dict[str, Any]],
|
|
137
|
+
preview: Dict[str, Any],
|
|
138
|
+
) -> Dict[str, Any]:
|
|
139
|
+
"""Mint a token and return the pending_user_decision response shape."""
|
|
140
|
+
token = uuid.uuid4().hex
|
|
141
|
+
expires_at = time.time() + self.ttl_seconds
|
|
142
|
+
with self.lock:
|
|
143
|
+
self.gc()
|
|
144
|
+
self.tokens[token] = {
|
|
145
|
+
"action": action,
|
|
146
|
+
"fingerprint": self.fingerprint(action, params),
|
|
147
|
+
"expires_at": expires_at,
|
|
148
|
+
"issued_at": time.time(),
|
|
149
|
+
}
|
|
150
|
+
body = self._err(
|
|
151
|
+
"This action is destructive. Re-call with confirm_token to proceed.",
|
|
152
|
+
code="CONFIRMATION_REQUIRED",
|
|
153
|
+
category="pending_user_decision",
|
|
154
|
+
retryable=False,
|
|
155
|
+
remediation=(
|
|
156
|
+
f"Re-call {action} with params.confirm_token={token!r}; "
|
|
157
|
+
f"token expires in {self.ttl_seconds}s."
|
|
158
|
+
),
|
|
159
|
+
)
|
|
160
|
+
body.update({
|
|
161
|
+
"status": "confirmation_required",
|
|
162
|
+
"confirm_token": token,
|
|
163
|
+
"preview": preview,
|
|
164
|
+
"expires_at_epoch": expires_at,
|
|
165
|
+
"ttl_seconds": self.ttl_seconds,
|
|
166
|
+
})
|
|
167
|
+
return body
|
|
168
|
+
|
|
169
|
+
def consume(
|
|
170
|
+
self,
|
|
171
|
+
*,
|
|
172
|
+
action: str,
|
|
173
|
+
params: Optional[Dict[str, Any]],
|
|
174
|
+
) -> Optional[Dict[str, Any]]:
|
|
175
|
+
"""Redeem a token.
|
|
176
|
+
|
|
177
|
+
Returns None when the call may proceed — which covers three distinct cases:
|
|
178
|
+
gating is switched off, no token was supplied (the caller is expected to
|
|
179
|
+
call `issue` in that case), or the token was valid and has now been spent.
|
|
180
|
+
Returns an error body when a token was supplied but is not good.
|
|
181
|
+
"""
|
|
182
|
+
if not self.required():
|
|
183
|
+
return None
|
|
184
|
+
token = (params or {}).get("confirm_token") or (params or {}).get("confirmToken")
|
|
185
|
+
if not token:
|
|
186
|
+
return None # Caller is expected to call issue() in this case.
|
|
187
|
+
with self.lock:
|
|
188
|
+
self.gc()
|
|
189
|
+
rec = self.tokens.pop(token, None) # one-time use, atomic with gc
|
|
190
|
+
if rec is None:
|
|
191
|
+
return self._err(
|
|
192
|
+
"confirm_token is invalid, expired, or was issued by a different "
|
|
193
|
+
"server instance (tokens are valid only on the instance that "
|
|
194
|
+
"issued them — e.g. a stdio-server token is not honored by the "
|
|
195
|
+
"networked server).",
|
|
196
|
+
code="CONFIRM_TOKEN_INVALID",
|
|
197
|
+
category="destructive_blocked",
|
|
198
|
+
retryable=False,
|
|
199
|
+
remediation=(
|
|
200
|
+
f"Re-call {action} without confirm_token on this instance to "
|
|
201
|
+
"receive a fresh token."
|
|
202
|
+
),
|
|
203
|
+
)
|
|
204
|
+
if rec.get("action") != action:
|
|
205
|
+
return self._err(
|
|
206
|
+
f"confirm_token issued for {rec.get('action')!r}, not {action!r}",
|
|
207
|
+
code="CONFIRM_TOKEN_ACTION_MISMATCH",
|
|
208
|
+
category="destructive_blocked",
|
|
209
|
+
retryable=False,
|
|
210
|
+
)
|
|
211
|
+
if rec.get("fingerprint") != self.fingerprint(action, params):
|
|
212
|
+
return self._err(
|
|
213
|
+
"confirm_token does not match the current params",
|
|
214
|
+
code="CONFIRM_TOKEN_FINGERPRINT_MISMATCH",
|
|
215
|
+
category="destructive_blocked",
|
|
216
|
+
retryable=False,
|
|
217
|
+
remediation=(
|
|
218
|
+
"Either re-issue the token with current params or roll back "
|
|
219
|
+
"the params change."
|
|
220
|
+
),
|
|
221
|
+
)
|
|
222
|
+
return None # OK to proceed
|