davinci-resolve-mcp 2.54.3 → 2.54.5

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 CHANGED
@@ -2,6 +2,58 @@
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.54.5
6
+
7
+ Reliability + security hardening from the exhaustive reliability audit (Wave A).
8
+
9
+ - **Security** `apply_spec` hooks no longer run with `shell=True`. A spec's hook
10
+ command is now `shlex`-split and executed without a shell, so a hook string
11
+ can't inject arbitrary shell (`; rm -rf …`, pipes, expansion). Hooks needing
12
+ shell features must invoke an interpreter explicitly (e.g. `bash -c "…"`).
13
+ - **Fixed** the confirm-token table (`_CONFIRM_TOKENS`) is now guarded by a lock.
14
+ The control panel is threaded, so issue/consume/GC ran concurrently; validate-
15
+ then-pop is now atomic and GC can't race a write.
16
+ - **Fixed** negative item indices are rejected instead of silently returning the
17
+ wrong item (Python reverse-indexing): `_get_item`, the audio item resolver, and
18
+ the two timeline-matte helpers now require `0 <= index < len`.
19
+ - **Fixed** history queries clamp `limit` to `[1, 1000]`. SQLite treats a negative
20
+ `LIMIT` as "no limit", so a negative value could silently fetch the entire table
21
+ (`get_brain_edit_history`, `list_runs`, `get_media_pool_change_history`).
22
+ `timeline_versioning` version/limit/keep_n params are now validated (no unhandled
23
+ `ValueError`; `rollback` rejects negative versions before archiving). `max_files`
24
+ /`max_seconds` in the relink file search are clamped positive.
25
+ - **Fixed** the server-reachable ffmpeg probes (render, audio, review) now pass
26
+ `timeout=120` so a hung ffmpeg can't block the server indefinitely.
27
+
28
+ (Deferred to a live-validated follow-up: confirm-token gating + archiving for
29
+ catastrophic media-pool/take/fusion deletes, and temp-directory lifecycle cleanup.)
30
+
31
+ ## What's New in v2.54.4
32
+
33
+ Persistence-safety hardening (generalizes issue #71 to the analysis state
34
+ stores). A multi-agent audit found several state files whose **readers** swallow a
35
+ `JSONDecodeError` and reset to an empty default — so a later read-modify-write
36
+ writes back only the new field, silently wiping prior data. Atomic writes don't
37
+ help, because the loss happens at *read* time.
38
+
39
+ - **Fixed** read-modify-write paths now read strictly and **refuse to overwrite**
40
+ a corrupt existing file (via a shared `ConfigParseError` + `_read_json_strict`),
41
+ rather than clobbering it:
42
+ - media-analysis **preferences** — 5 write paths (set-defaults, timed-marker
43
+ default, sampling-mode default, AI governance, caps preset)
44
+ - **update-state** — configure/clear update settings (3 paths), now also written
45
+ atomically (temp + `os.replace`)
46
+ - per-clip **corrections** (human edit history — the highest-value store) —
47
+ update and revert paths
48
+ - **Fixed** non-atomic writes that a crash mid-write could truncate: the bin
49
+ summary markdown and Fusion `.setting` files now write to a temp file and
50
+ `os.replace`.
51
+ - **Fixed** a read-modify-write race on `analysis.json` under the threaded control
52
+ panel: transcript regeneration is now serialized under the existing state lock,
53
+ so a concurrent regen/batch write can't drop the other's updates.
54
+ - Read-only callers keep their forgiving behavior (they fall back to defaults and
55
+ never write). New tests in `tests/test_persistence_safety.py`.
56
+
5
57
  ## What's New in v2.54.3
6
58
 
7
59
  Follow-up to the v2.54.2 config-merge fix (#71): the JSONC sanitizer's
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # DaVinci Resolve MCP Server
2
2
 
3
- [![Version](https://img.shields.io/badge/version-2.54.3-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.54.5-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
4
4
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
5
5
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
6
6
  [![Tools](https://img.shields.io/badge/MCP%20Tools-34%20(341%20full)-blue.svg)](#server-modes)
package/install.py CHANGED
@@ -35,7 +35,7 @@ from src.utils.update_check import (
35
35
 
36
36
  # ─── Version ──────────────────────────────────────────────────────────────────
37
37
 
38
- VERSION = "2.54.3"
38
+ VERSION = "2.54.5"
39
39
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
40
40
  # Resolve's scripting bridge loads into newer interpreters on recent builds
41
41
  # (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.54.3",
3
+ "version": "2.54.5",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -15176,14 +15176,20 @@ class Handler(BaseHTTPRequestHandler):
15176
15176
  self._json({"success": False, "error": "Transcript regeneration is loopback-only."}, HTTPStatus.FORBIDDEN)
15177
15177
  return
15178
15178
  clip_id = path.split("/")[3]
15179
- self._json(regenerate_clip_transcript(
15180
- self.state.project_root,
15181
- clip_id,
15182
- with_words=bool(body.get("with_words", True)),
15183
- backend=body.get("backend") or None,
15184
- language=body.get("language") or None,
15185
- model=body.get("model") or None,
15186
- ))
15179
+ # Serialize the analysis.json read-modify-write: this server is
15180
+ # threaded, so concurrent regenerations (or a regen racing a batch
15181
+ # job's report write) would interleave and the last writer would drop
15182
+ # the other's updates (PS9).
15183
+ with self.state.lock:
15184
+ result = regenerate_clip_transcript(
15185
+ self.state.project_root,
15186
+ clip_id,
15187
+ with_words=bool(body.get("with_words", True)),
15188
+ backend=body.get("backend") or None,
15189
+ language=body.get("language") or None,
15190
+ model=body.get("model") or None,
15191
+ )
15192
+ self._json(result)
15187
15193
  return
15188
15194
  if path.startswith("/api/clips/") and path.endswith("/transcript/corrections"):
15189
15195
  if not _request_is_loopback(self):
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
80
80
  handlers=[logging.StreamHandler()],
81
81
  )
82
82
 
83
- VERSION = "2.54.3"
83
+ VERSION = "2.54.5"
84
84
  logger = logging.getLogger("davinci-resolve-mcp")
85
85
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
86
86
  logger.info(f"Detected platform: {get_platform()}")
@@ -574,7 +574,7 @@ def get_timeline_matte_list(item_index: int = 0, track_type: str = "video", trac
574
574
  if not tl:
575
575
  return {"error": "No current timeline"}
576
576
  items = tl.GetItemListInTrack(track_type, track_index)
577
- if not items or item_index >= len(items):
577
+ if not items or item_index < 0 or item_index >= len(items): # reject negatives (EX5)
578
578
  return {"error": f"No item at index {item_index}"}
579
579
  mattes = mp.GetTimelineMatteList(items[item_index])
580
580
  return {"mattes": mattes if mattes else []}
@@ -171,7 +171,7 @@ def add_timeline_mattes_to_media_pool(timeline_item_index: int, matte_paths: Lis
171
171
  return {"error": "No current timeline"}
172
172
 
173
173
  items = timeline.GetItemListInTrack(track_type, track_index)
174
- if not items or timeline_item_index >= len(items):
174
+ if not items or timeline_item_index < 0 or timeline_item_index >= len(items): # reject negatives (EX5)
175
175
  return {"error": f"Timeline item at index {timeline_item_index} not found"}
176
176
 
177
177
  item = items[timeline_item_index]
package/src/server.py CHANGED
@@ -11,7 +11,7 @@ Usage:
11
11
  python src/server.py --full # Start the 341-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.54.3"
14
+ VERSION = "2.54.5"
15
15
 
16
16
  import base64
17
17
  import os
@@ -1006,6 +1006,10 @@ import uuid as _uuid
1006
1006
 
1007
1007
 
1008
1008
  _CONFIRM_TOKENS: Dict[str, Dict[str, Any]] = {}
1009
+ # The control panel runs on a threaded HTTP server, so issue/consume/gc of the
1010
+ # token table run on concurrent threads. Guard every access so a GC iteration
1011
+ # can't race a write and validate-then-pop stays atomic (EX4).
1012
+ _CONFIRM_TOKENS_LOCK = threading.RLock()
1009
1013
  _CONFIRM_TTL_SECONDS = 300
1010
1014
 
1011
1015
 
@@ -1023,11 +1027,13 @@ def _confirm_token_fingerprint(action: str, params: Optional[Dict[str, Any]]) ->
1023
1027
 
1024
1028
 
1025
1029
  def _confirm_token_gc():
1026
- """Drop expired tokens; called on every issue/validate."""
1030
+ """Drop expired tokens; called on every issue/validate. Caller may already
1031
+ hold _CONFIRM_TOKENS_LOCK (RLock makes re-entry safe)."""
1027
1032
  now = _time.time()
1028
- expired = [t for t, rec in _CONFIRM_TOKENS.items() if rec.get("expires_at", 0) < now]
1029
- for t in expired:
1030
- _CONFIRM_TOKENS.pop(t, None)
1033
+ with _CONFIRM_TOKENS_LOCK:
1034
+ expired = [t for t, rec in _CONFIRM_TOKENS.items() if rec.get("expires_at", 0) < now]
1035
+ for t in expired:
1036
+ _CONFIRM_TOKENS.pop(t, None)
1031
1037
 
1032
1038
 
1033
1039
  def _confirm_token_required() -> bool:
@@ -1045,16 +1051,17 @@ def _confirm_token_required() -> bool:
1045
1051
 
1046
1052
  def _issue_confirm_token(*, action: str, params: Optional[Dict[str, Any]], preview: Dict[str, Any]) -> Dict[str, Any]:
1047
1053
  """Mint a token. Returns the pending_user_decision response shape."""
1048
- _confirm_token_gc()
1049
1054
  token = _uuid.uuid4().hex
1050
1055
  fp = _confirm_token_fingerprint(action, params)
1051
1056
  expires_at = _time.time() + _CONFIRM_TTL_SECONDS
1052
- _CONFIRM_TOKENS[token] = {
1053
- "action": action,
1054
- "fingerprint": fp,
1055
- "expires_at": expires_at,
1056
- "issued_at": _time.time(),
1057
- }
1057
+ with _CONFIRM_TOKENS_LOCK:
1058
+ _confirm_token_gc()
1059
+ _CONFIRM_TOKENS[token] = {
1060
+ "action": action,
1061
+ "fingerprint": fp,
1062
+ "expires_at": expires_at,
1063
+ "issued_at": _time.time(),
1064
+ }
1058
1065
  body = _err(
1059
1066
  f"This action is destructive. Re-call with confirm_token to proceed.",
1060
1067
  code="CONFIRMATION_REQUIRED",
@@ -1082,8 +1089,9 @@ def _consume_confirm_token(*, action: str, params: Optional[Dict[str, Any]]) ->
1082
1089
  token = (params or {}).get("confirm_token") or (params or {}).get("confirmToken")
1083
1090
  if not token:
1084
1091
  return None # Caller is expected to call _issue_confirm_token in this case.
1085
- _confirm_token_gc()
1086
- rec = _CONFIRM_TOKENS.pop(token, None) # one-time use
1092
+ with _CONFIRM_TOKENS_LOCK:
1093
+ _confirm_token_gc()
1094
+ rec = _CONFIRM_TOKENS.pop(token, None) # one-time use, atomic with gc
1087
1095
  if rec is None:
1088
1096
  return _err(
1089
1097
  "confirm_token is invalid, expired, or was issued by a different "
@@ -1855,7 +1863,9 @@ def _get_item(p):
1855
1863
  track_index = p.get("track_index", 1)
1856
1864
  item_index = p.get("item_index", 0)
1857
1865
  items = tl.GetItemListInTrack(track_type, track_index)
1858
- if not items or item_index >= len(items):
1866
+ # Reject negatives explicitly: Python's reverse-indexing would otherwise
1867
+ # silently return the wrong item for item_index < 0 (EX5).
1868
+ if not items or not isinstance(item_index, int) or item_index < 0 or item_index >= len(items):
1859
1869
  return tl, None, _err(
1860
1870
  f"No item at index {item_index} on {track_type} track {track_index}",
1861
1871
  code="ITEM_INDEX_OUT_OF_RANGE", category="invalid_input",
@@ -5494,8 +5504,10 @@ def _bounded_basename_matches(
5494
5504
  p: Dict[str, Any],
5495
5505
  ) -> Tuple[List[str], Dict[str, Any]]:
5496
5506
  started = time.monotonic()
5497
- max_seconds = float(p.get("max_seconds", p.get("timeout_seconds", 20)) or 20)
5498
- max_files = int(p.get("max_files_scanned", p.get("maxFilesScanned", 50000)) or 50000)
5507
+ # Clamp to positive: a negative max_seconds/max_files would trip the
5508
+ # >= guards on the first iteration and silently return no results (EX8).
5509
+ max_seconds = max(0.1, float(p.get("max_seconds", p.get("timeout_seconds", 20)) or 20))
5510
+ max_files = max(1, int(p.get("max_files_scanned", p.get("maxFilesScanned", 50000)) or 50000))
5499
5511
  max_depth_raw = p.get("max_depth", p.get("maxDepth"))
5500
5512
  max_depth = int(max_depth_raw) if max_depth_raw is not None else None
5501
5513
  all_matches = bool(p.get("all_matches", False))
@@ -5704,7 +5716,7 @@ def _audio_item_from_params(tl, p: Dict[str, Any]):
5704
5716
  track_index = int(p.get("track_index", 1))
5705
5717
  item_index = int(p.get("item_index", 0))
5706
5718
  items = tl.GetItemListInTrack(track_type, track_index) or []
5707
- if item_index >= len(items):
5719
+ if item_index < 0 or item_index >= len(items): # reject negatives (EX5)
5708
5720
  return None, _err(f"No item at index {item_index} on {track_type} track {track_index}")
5709
5721
  return items[item_index], None
5710
5722
 
@@ -7273,6 +7285,39 @@ def _media_analysis_preferences_path() -> str:
7273
7285
  return os.path.join(project_dir, "logs", "media-analysis-preferences.json")
7274
7286
 
7275
7287
 
7288
+ class ConfigParseError(Exception):
7289
+ """A persisted state/config file exists but its contents could not be read.
7290
+
7291
+ Read-modify-write callers MUST refuse to overwrite when this is raised — a
7292
+ transient/corrupt read returning a default, followed by a write, silently
7293
+ wipes the user's prior data. This is the issue-#71 bug class generalized to
7294
+ the analysis state stores (see local gameplan Phase 5 / PS1–PS3).
7295
+ """
7296
+
7297
+
7298
+ def _read_json_strict(path: str) -> Dict[str, Any]:
7299
+ """Read JSON for a read-modify-write cycle.
7300
+
7301
+ Returns ``{}`` only when the file is absent or empty (safe to create). Raises
7302
+ :class:`ConfigParseError` when the file exists but cannot be read/parsed, so
7303
+ the caller can refuse to overwrite and avoid clobbering prior contents.
7304
+ """
7305
+ try:
7306
+ with open(path, "r", encoding="utf-8") as handle:
7307
+ raw = handle.read()
7308
+ except FileNotFoundError:
7309
+ return {}
7310
+ except OSError as exc:
7311
+ raise ConfigParseError(f"{path}: {exc}") from exc
7312
+ if not raw.strip():
7313
+ return {}
7314
+ try:
7315
+ payload = json.loads(raw)
7316
+ except json.JSONDecodeError as exc:
7317
+ raise ConfigParseError(f"{path}: {exc}") from exc
7318
+ return payload if isinstance(payload, dict) else {}
7319
+
7320
+
7276
7321
  def _read_media_analysis_preferences() -> Dict[str, Any]:
7277
7322
  path = _media_analysis_preferences_path()
7278
7323
  try:
@@ -7283,6 +7328,12 @@ def _read_media_analysis_preferences() -> Dict[str, Any]:
7283
7328
  return {}
7284
7329
 
7285
7330
 
7331
+ def _read_media_analysis_preferences_strict() -> Dict[str, Any]:
7332
+ """Strict prefs read for read-modify-write paths — raises ConfigParseError on
7333
+ a corrupt existing file so the caller refuses to overwrite (PS1)."""
7334
+ return _read_json_strict(_media_analysis_preferences_path())
7335
+
7336
+
7286
7337
  def _write_media_analysis_preferences(preferences: Dict[str, Any]) -> None:
7287
7338
  # Atomic replace: a crash mid-write must not truncate the file, because the
7288
7339
  # reader resets to {} on corruption and the next save would then wipe every
@@ -7435,6 +7486,23 @@ def _setup_positive_int(value: Any, default: int, min_value: int = 0, max_value:
7435
7486
  return max(min_value, min(parsed, max_value))
7436
7487
 
7437
7488
 
7489
+ def _safe_int(value: Any, default: int, *, minimum: Optional[int] = None, maximum: Optional[int] = None) -> int:
7490
+ """Coerce a tool param to int without throwing; clamp to [minimum, maximum].
7491
+
7492
+ Used by action handlers so a non-numeric or out-of-range numeric param yields
7493
+ a sane value instead of an unhandled ValueError or a SQLite "negative LIMIT =
7494
+ no limit" full-table fetch (EX8)."""
7495
+ try:
7496
+ parsed = int(value)
7497
+ except (TypeError, ValueError):
7498
+ parsed = default
7499
+ if minimum is not None:
7500
+ parsed = max(minimum, parsed)
7501
+ if maximum is not None:
7502
+ parsed = min(parsed, maximum)
7503
+ return parsed
7504
+
7505
+
7438
7506
  def _setup_marker_limit(value: Any, default: int = 12) -> int:
7439
7507
  if isinstance(value, str) and _setup_text_key(value) in {"unlimited", "no_limit", "nolimit", "all"}:
7440
7508
  return 0
@@ -7669,6 +7737,12 @@ def _media_analysis_timed_marker_decision(p: Dict[str, Any]) -> Dict[str, Any]:
7669
7737
 
7670
7738
  if choice in {"default_yes", "default_no"}:
7671
7739
  saved = "yes" if choice == "default_yes" else "no"
7740
+ # Strict re-read before writing: a corrupt prefs file must refuse, not
7741
+ # clobber every saved preference (PS1).
7742
+ try:
7743
+ preferences = _read_media_analysis_preferences_strict()
7744
+ except ConfigParseError as exc:
7745
+ return _err(f"Refusing to save timed-marker default: {exc}. The preferences file exists but is unparseable; fix or delete it to avoid wiping saved settings.")
7672
7746
  preferences["timed_markers_default"] = saved
7673
7747
  preferences["timed_markers_default_updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
7674
7748
  _write_media_analysis_preferences(preferences)
@@ -7833,6 +7907,10 @@ def _media_analysis_sampling_mode_decision(p: Dict[str, Any]) -> Dict[str, Any]:
7833
7907
  source = "explicit"
7834
7908
  if save_default:
7835
7909
  saved = mode
7910
+ try:
7911
+ preferences = _read_media_analysis_preferences_strict()
7912
+ except ConfigParseError as exc:
7913
+ return _err(f"Refusing to save sampling-mode default: {exc}. The preferences file exists but is unparseable; fix or delete it to avoid wiping saved settings.")
7836
7914
  preferences["sampling_mode_default"] = mode
7837
7915
  preferences["sampling_mode_default_updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
7838
7916
  _write_media_analysis_preferences(preferences)
@@ -11040,6 +11118,31 @@ def _setup_update_state() -> Dict[str, Any]:
11040
11118
  return {}
11041
11119
 
11042
11120
 
11121
+ def _setup_update_state_strict() -> Dict[str, Any]:
11122
+ """Strict update-state read for read-modify-write paths — raises
11123
+ ConfigParseError on a corrupt existing file so the caller refuses to
11124
+ overwrite and wipe saved update prefs (PS2)."""
11125
+ return _read_json_strict(str(update_state_path(project_dir)))
11126
+
11127
+
11128
+ def _write_setup_update_state(state: Dict[str, Any]) -> None:
11129
+ """Atomically persist update state (temp + os.replace), so a crash mid-write
11130
+ can't truncate the file that _setup_update_state then resets to {} (PS2)."""
11131
+ path = str(update_state_path(project_dir))
11132
+ os.makedirs(os.path.dirname(path), exist_ok=True)
11133
+ tmp_path = f"{path}.tmp-{os.getpid()}-{threading.get_ident()}-{time.time_ns()}"
11134
+ try:
11135
+ with open(tmp_path, "w", encoding="utf-8") as handle:
11136
+ json.dump(state, handle, indent=2, sort_keys=True)
11137
+ handle.write("\n")
11138
+ os.replace(tmp_path, path)
11139
+ finally:
11140
+ try:
11141
+ os.remove(tmp_path)
11142
+ except OSError:
11143
+ pass
11144
+
11145
+
11043
11146
  def _setup_update_env() -> Dict[str, str]:
11044
11147
  env = dict(os.environ)
11045
11148
  state = _setup_update_state()
@@ -11263,7 +11366,13 @@ def _setup_set_media_analysis_defaults(media_defaults: Dict[str, Any], dry_run:
11263
11366
  if not requested:
11264
11367
  return {"changed": False, "recognized": False}
11265
11368
 
11266
- preferences = _read_media_analysis_preferences()
11369
+ # Strict read: this is a read-modify-write of the prefs file, so a corrupt
11370
+ # existing file must refuse rather than seed the write from {} and wipe every
11371
+ # saved preference (PS1).
11372
+ try:
11373
+ preferences = _read_media_analysis_preferences_strict()
11374
+ except ConfigParseError as exc:
11375
+ return _err(f"Refusing to update media-analysis defaults: {exc}. The preferences file exists but is unparseable; fix or delete it to avoid wiping saved settings.")
11267
11376
  before = _media_analysis_effective_preferences()
11268
11377
  next_preferences = dict(preferences)
11269
11378
  updates: Dict[str, Dict[str, Any]] = {}
@@ -11519,9 +11628,18 @@ def _setup_set_updates_defaults(update_defaults: Dict[str, Any], dry_run: bool)
11519
11628
  if dry_run:
11520
11629
  return {"changed": True, "recognized": True, "updates": updates, "before": before, "dry_run": True}
11521
11630
 
11631
+ # Strict re-read before mutating: a corrupt update-state file must refuse,
11632
+ # not seed the write from {} and wipe saved update prefs (PS2).
11633
+ try:
11634
+ state = _setup_update_state_strict()
11635
+ except ConfigParseError as exc:
11636
+ return _err(f"Refusing to update settings: {exc}. The update-state file exists but is unparseable; fix or delete it to avoid wiping saved update preferences.")
11522
11637
  if "mode" in updates:
11523
11638
  set_update_mode(project_dir, updates["mode"]["after"], env=_setup_update_env())
11524
- state = _setup_update_state()
11639
+ try:
11640
+ state = _setup_update_state_strict()
11641
+ except ConfigParseError as exc:
11642
+ return _err(f"Refusing to update settings: {exc}. The update-state file became unparseable; fix or delete it to avoid wiping saved update preferences.")
11525
11643
  updated_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
11526
11644
  if "check_interval_hours" in updates:
11527
11645
  state["check_interval_hours"] = updates["check_interval_hours"]["after"]
@@ -11529,9 +11647,7 @@ def _setup_set_updates_defaults(update_defaults: Dict[str, Any], dry_run: bool)
11529
11647
  state["snooze_hours"] = updates["snooze_hours"]["after"]
11530
11648
  if updates:
11531
11649
  state["setup_defaults_updated_at"] = updated_at
11532
- with open(update_state_path(project_dir), "w", encoding="utf-8") as handle:
11533
- json.dump(state, handle, indent=2, sort_keys=True)
11534
- handle.write("\n")
11650
+ _write_setup_update_state(state)
11535
11651
  return {
11536
11652
  "changed": bool(updates),
11537
11653
  "recognized": True,
@@ -11602,26 +11718,28 @@ def _setup_clear_defaults(keys: Any, dry_run: bool) -> Dict[str, Any]:
11602
11718
  result["cleared"].append("updates.mode")
11603
11719
 
11604
11720
  if clear_all or normalized_keys & {"updates.check_interval_hours", "check_interval_hours", "update_interval_hours"}:
11605
- state = _setup_update_state()
11606
11721
  if dry_run:
11607
11722
  result["update_check_interval_hours"] = {"changed": True, "dry_run": True}
11608
11723
  else:
11724
+ try:
11725
+ state = _setup_update_state_strict()
11726
+ except ConfigParseError as exc:
11727
+ return _err(f"Refusing to clear update interval: {exc}. The update-state file exists but is unparseable; fix or delete it to avoid wiping saved update preferences.")
11609
11728
  state.pop("check_interval_hours", None)
11610
- with open(update_state_path(project_dir), "w", encoding="utf-8") as handle:
11611
- json.dump(state, handle, indent=2, sort_keys=True)
11612
- handle.write("\n")
11729
+ _write_setup_update_state(state)
11613
11730
  result["update_check_interval_hours"] = {"changed": True, "state_path": str(update_state_path(project_dir))}
11614
11731
  result["cleared"].append("updates.check_interval_hours")
11615
11732
 
11616
11733
  if clear_all or normalized_keys & {"updates.snooze_hours", "snooze_hours", "update_snooze_hours"}:
11617
- state = _setup_update_state()
11618
11734
  if dry_run:
11619
11735
  result["update_snooze_hours"] = {"changed": True, "dry_run": True}
11620
11736
  else:
11737
+ try:
11738
+ state = _setup_update_state_strict()
11739
+ except ConfigParseError as exc:
11740
+ return _err(f"Refusing to clear snooze: {exc}. The update-state file exists but is unparseable; fix or delete it to avoid wiping saved update preferences.")
11621
11741
  state.pop("snooze_hours", None)
11622
- with open(update_state_path(project_dir), "w", encoding="utf-8") as handle:
11623
- json.dump(state, handle, indent=2, sort_keys=True)
11624
- handle.write("\n")
11742
+ _write_setup_update_state(state)
11625
11743
  result["update_snooze_hours"] = {"changed": True, "state_path": str(update_state_path(project_dir))}
11626
11744
  result["cleared"].append("updates.snooze_hours")
11627
11745
 
@@ -11988,7 +12106,7 @@ def _v2_corrections_path_for_clip(project_root: str, clip_dir: Optional[str], cl
11988
12106
  return None
11989
12107
 
11990
12108
 
11991
- def _v2_read_corrections(path: str) -> Dict[str, Any]:
12109
+ def _v2_read_corrections(path: str, *, strict: bool = False) -> Dict[str, Any]:
11992
12110
  if not os.path.isfile(path):
11993
12111
  return {"schema_version": "2.0", "current": {}, "changelog": []}
11994
12112
  try:
@@ -12001,7 +12119,12 @@ def _v2_read_corrections(path: str) -> Dict[str, Any]:
12001
12119
  data.setdefault("current", {})
12002
12120
  data.setdefault("changelog", [])
12003
12121
  return data
12004
- except (OSError, json.JSONDecodeError):
12122
+ except (OSError, json.JSONDecodeError) as exc:
12123
+ # strict=True is for read-modify-write callers: an existing-but-corrupt
12124
+ # corrections file must refuse, not reset to empty and then erase the
12125
+ # user's human edit history on the next write (PS3, issue #71 class).
12126
+ if strict:
12127
+ raise ConfigParseError(f"{path}: {exc}") from exc
12005
12128
  return {"schema_version": "2.0", "current": {}, "changelog": []}
12006
12129
 
12007
12130
 
@@ -12049,7 +12172,10 @@ def _v2_update_field(project_root: str, p: Dict[str, Any], *, entity_type: str)
12049
12172
  if not path:
12050
12173
  return _err(f"Could not locate clip directory for clip_id={clip_id} under {project_root}/clips/. Pass clip_dir explicitly if the clip directory is non-standard.")
12051
12174
 
12052
- data = _v2_read_corrections(path)
12175
+ try:
12176
+ data = _v2_read_corrections(path, strict=True)
12177
+ except ConfigParseError as exc:
12178
+ return _err(f"Refusing to write correction: {exc}. The corrections file exists but is unparseable; fix or remove it to avoid wiping the human edit history.")
12053
12179
  if clip_id and "clip_id" not in data:
12054
12180
  data["clip_id"] = str(clip_id)
12055
12181
 
@@ -12149,7 +12275,10 @@ def _v2_revert_field(project_root: str, p: Dict[str, Any]) -> Dict[str, Any]:
12149
12275
  path = _v2_corrections_path_for_clip(project_root, clip_dir, clip_id)
12150
12276
  if not path or not os.path.isfile(path):
12151
12277
  return _err("No corrections file exists for this clip; nothing to revert.")
12152
- data = _v2_read_corrections(path)
12278
+ try:
12279
+ data = _v2_read_corrections(path, strict=True)
12280
+ except ConfigParseError as exc:
12281
+ return _err(f"Refusing to revert: {exc}. The corrections file exists but is unparseable; fix or remove it to avoid wiping the human edit history.")
12153
12282
  key = f"{entity_type}:{entity_uuid}:{field_path}"
12154
12283
  if key not in data.get("current", {}):
12155
12284
  return _err(f"No current correction for {key}; nothing to revert.")
@@ -13573,14 +13702,29 @@ class _SpecLiveExecutor:
13573
13702
 
13574
13703
 
13575
13704
  def _make_spec_hook_runner(timeout: float = 120.0):
13576
- """Return a callable that runs a Hook's shell command (opt-in only)."""
13705
+ """Return a callable that runs a Hook's command (opt-in only).
13706
+
13707
+ The command is split with shlex and run WITHOUT a shell, so a spec's hook
13708
+ string cannot inject arbitrary shell (`; rm -rf …`, pipes, expansion). A
13709
+ hook that needs shell features must invoke an explicit interpreter
13710
+ (e.g. ``bash -c "…"``) as its own argv, making the intent visible (EX1).
13711
+ """
13712
+ import shlex
13577
13713
  import subprocess
13578
13714
 
13579
13715
  def _run(hook) -> bool:
13716
+ try:
13717
+ argv = shlex.split(hook.command or "")
13718
+ except ValueError as exc:
13719
+ logger.warning("spec hook '%s' has an unparseable command: %s", hook.name or hook.command, exc)
13720
+ return False
13721
+ if not argv:
13722
+ logger.warning("spec hook '%s' has an empty command", hook.name or "")
13723
+ return False
13580
13724
  try:
13581
13725
  proc = subprocess.run(
13582
- hook.command,
13583
- shell=True,
13726
+ argv,
13727
+ shell=False,
13584
13728
  timeout=timeout,
13585
13729
  stdin=subprocess.DEVNULL,
13586
13730
  capture_output=True,
@@ -15865,7 +16009,7 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
15865
16009
  _r, _proj, project_root, _name = vctx
15866
16010
  session_only = bool(p.get("session_only", False))
15867
16011
  session_id = _AI_LEDGER_SESSION_ID if session_only else None
15868
- limit = int(p.get("limit", 50))
16012
+ limit = _safe_int(p.get("limit"), 50, minimum=1, maximum=1000)
15869
16013
  return {
15870
16014
  "success": True,
15871
16015
  "session_id": _AI_LEDGER_SESSION_ID,
@@ -15896,7 +16040,10 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
15896
16040
  return _err(f"unknown tier '{preset}'. Valid: {sorted(_resolve_ai_governance.VALID_TIERS)}")
15897
16041
  if mode and mode not in _AI_GOVERNANCE_MODES:
15898
16042
  return _err(f"unknown mode '{mode}'. Valid: {list(_AI_GOVERNANCE_MODES)}")
15899
- prefs = _read_media_analysis_preferences()
16043
+ try:
16044
+ prefs = _read_media_analysis_preferences_strict()
16045
+ except ConfigParseError as exc:
16046
+ return _err(f"Refusing to save AI governance: {exc}. The preferences file exists but is unparseable; fix or delete it to avoid wiping saved settings.")
15900
16047
  if preset:
15901
16048
  prefs["resolve_ai_governance_preset"] = preset
15902
16049
  if mode:
@@ -15916,7 +16063,10 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
15916
16063
  if preset not in _ac.VALID_PRESETS:
15917
16064
  return _err(f"unknown preset '{preset}'. Valid: {sorted(_ac.VALID_PRESETS)}")
15918
16065
  # Update preference file directly (same pattern as set_defaults).
15919
- prefs = _read_media_analysis_preferences()
16066
+ try:
16067
+ prefs = _read_media_analysis_preferences_strict()
16068
+ except ConfigParseError as exc:
16069
+ return _err(f"Refusing to save caps preset: {exc}. The preferences file exists but is unparseable; fix or delete it to avoid wiping saved settings.")
15920
16070
  prefs["analysis_caps_preset"] = preset
15921
16071
  if isinstance(p.get("overrides"), dict):
15922
16072
  prefs["analysis_caps_overrides"] = p["overrides"]
@@ -16715,7 +16865,7 @@ def timeline_versioning(action: str, params: Optional[Dict[str, Any]] = None) ->
16715
16865
  if action == "list_runs":
16716
16866
  return {
16717
16867
  "success": True,
16718
- "runs": _analysis_runs.list_runs(project_root, limit=int(p.get("limit", 50))),
16868
+ "runs": _analysis_runs.list_runs(project_root, limit=_safe_int(p.get("limit"), 50, minimum=1, maximum=1000)),
16719
16869
  }
16720
16870
  if action == "archive_current":
16721
16871
  return _timeline_versioning.archive_current_timeline(
@@ -16736,13 +16886,18 @@ def timeline_versioning(action: str, params: Optional[Dict[str, Any]] = None) ->
16736
16886
  if action == "diff_versions":
16737
16887
  if not p.get("timeline_name") or "from_version" not in p or "to_version" not in p:
16738
16888
  return _err("timeline_name, from_version, to_version required")
16889
+ try:
16890
+ from_version = int(p["from_version"])
16891
+ to_version = int(p["to_version"])
16892
+ except (TypeError, ValueError):
16893
+ return _err("from_version and to_version must be integers")
16739
16894
  return {
16740
16895
  "success": True,
16741
16896
  **_timeline_versioning.diff_versions(
16742
16897
  project_root=project_root,
16743
16898
  timeline_name=str(p["timeline_name"]),
16744
- from_version=int(p["from_version"]),
16745
- to_version=int(p["to_version"]),
16899
+ from_version=from_version,
16900
+ to_version=to_version,
16746
16901
  ),
16747
16902
  }
16748
16903
  if action == "diff_timelines":
@@ -16758,18 +16913,24 @@ def timeline_versioning(action: str, params: Optional[Dict[str, Any]] = None) ->
16758
16913
  project_root=project_root,
16759
16914
  timeline_name=p.get("timeline_name"),
16760
16915
  analysis_run_id=p.get("analysis_run_id"),
16761
- limit=int(p.get("limit", 50)),
16916
+ limit=_safe_int(p.get("limit"), 50, minimum=1, maximum=1000),
16762
16917
  )
16763
16918
  return {"success": True, "edits": rows}
16764
16919
  if action == "rollback":
16765
16920
  if not p.get("timeline_name") or "version" not in p:
16766
16921
  return _err("timeline_name and version required")
16922
+ try:
16923
+ version = int(p["version"])
16924
+ except (TypeError, ValueError):
16925
+ return _err("version must be an integer")
16926
+ if version < 0:
16927
+ return _err("version must be >= 0")
16767
16928
  return _timeline_versioning.rollback_to_version(
16768
16929
  resolve=resolve_h,
16769
16930
  project=project_h,
16770
16931
  project_root=project_root,
16771
16932
  timeline_name=str(p["timeline_name"]),
16772
- version=int(p["version"]),
16933
+ version=version,
16773
16934
  analysis_run_id=p.get("analysis_run_id"),
16774
16935
  )
16775
16936
  if action == "prune":
@@ -16780,7 +16941,7 @@ def timeline_versioning(action: str, params: Optional[Dict[str, Any]] = None) ->
16780
16941
  project=project_h,
16781
16942
  project_root=project_root,
16782
16943
  timeline_name=str(p["timeline_name"]),
16783
- keep_n=int(p.get("keep_n", 10)),
16944
+ keep_n=_safe_int(p.get("keep_n"), 10, minimum=1, maximum=1000),
16784
16945
  )
16785
16946
  if action == "registry":
16786
16947
  return {"success": True, **_brain_edits.read_brain_edits_registry(project_root)}
@@ -16791,7 +16952,7 @@ def timeline_versioning(action: str, params: Optional[Dict[str, Any]] = None) ->
16791
16952
  project_root=project_root,
16792
16953
  analysis_run_id=p.get("analysis_run_id"),
16793
16954
  action=p.get("media_pool_action"),
16794
- limit=int(p.get("limit", 50)),
16955
+ limit=_safe_int(p.get("limit"), 50, minimum=1, maximum=1000),
16795
16956
  ),
16796
16957
  }
16797
16958
  return _err(f"Unknown action: {action}")
@@ -358,9 +358,13 @@ def regenerate_bin_summary_from_manifest(
358
358
  lines.append("\n")
359
359
 
360
360
  path = bin_summary_path(project_root)
361
+ # Atomic write (temp + os.replace): a crash mid-write must not truncate the
362
+ # bin summary that session_start_context reads at startup (PS4).
363
+ tmp_path = path + ".tmp"
361
364
  try:
362
- with open(path, "w", encoding="utf-8") as handle:
365
+ with open(tmp_path, "w", encoding="utf-8") as handle:
363
366
  handle.writelines(lines)
367
+ os.replace(tmp_path, path)
364
368
  return {
365
369
  "success": True,
366
370
  "path": path,
@@ -376,6 +380,11 @@ def regenerate_bin_summary_from_manifest(
376
380
  },
377
381
  }
378
382
  except OSError as exc:
383
+ if os.path.isfile(tmp_path):
384
+ try:
385
+ os.remove(tmp_path)
386
+ except OSError:
387
+ pass
379
388
  return {"success": False, "error": f"{type(exc).__name__}: {exc}"}
380
389
 
381
390
 
@@ -234,6 +234,11 @@ def get_run(project_root: str, analysis_run_id: str) -> Optional[Dict[str, Any]]
234
234
 
235
235
 
236
236
  def list_runs(project_root: str, *, limit: int = 50) -> List[Dict[str, Any]]:
237
+ # SQLite treats a negative LIMIT as "no limit"; clamp (EX8).
238
+ try:
239
+ limit = max(1, min(1000, int(limit)))
240
+ except (TypeError, ValueError):
241
+ limit = 50
237
242
  conn = timeline_brain_db.connect(project_root)
238
243
  rows = conn.execute(
239
244
  """
@@ -47,7 +47,7 @@ def _record_tool_result(
47
47
 
48
48
 
49
49
  def _run_ffmpeg(args: list[str]) -> None:
50
- subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", *args], check=True)
50
+ subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", *args], check=True, timeout=120)
51
51
 
52
52
 
53
53
  def _make_synthetic_media(work_dir: Path) -> Dict[str, Path]:
@@ -278,6 +278,12 @@ def get_brain_edit_history(
278
278
  limit: int = 50,
279
279
  ) -> List[Dict[str, Any]]:
280
280
  """Return brain_edit rows, optionally filtered by timeline or run, newest first."""
281
+ # SQLite treats a negative LIMIT as "no limit"; clamp so a negative/huge
282
+ # limit can't silently fetch the whole table (EX8).
283
+ try:
284
+ limit = max(1, min(1000, int(limit)))
285
+ except (TypeError, ValueError):
286
+ limit = 50
281
287
  conn = timeline_brain_db.connect(project_root)
282
288
  clauses: List[str] = []
283
289
  args: List[Any] = []
@@ -304,8 +304,19 @@ def splice_inputs_block(
304
304
  after_inputs = parse_instance_input_block(new_inner)
305
305
 
306
306
  os.makedirs(os.path.dirname(os.path.abspath(dest_path)) or ".", exist_ok=True)
307
- with open(dest_path, "w", encoding="utf-8", newline="\n") as handle:
308
- handle.write(new_content)
307
+ # Atomic write (temp + os.replace): a crash mid-write must not truncate the
308
+ # Fusion .setting file and leave the GroupOperator config uneditable (PS5).
309
+ tmp_path = f"{dest_path}.tmp-{os.getpid()}"
310
+ try:
311
+ with open(tmp_path, "w", encoding="utf-8", newline="\n") as handle:
312
+ handle.write(new_content)
313
+ os.replace(tmp_path, dest_path)
314
+ finally:
315
+ if os.path.isfile(tmp_path):
316
+ try:
317
+ os.remove(tmp_path)
318
+ except OSError:
319
+ pass
309
320
 
310
321
  return {
311
322
  "source_path": os.path.abspath(source_path),
@@ -84,6 +84,11 @@ def get_media_pool_change_history(
84
84
  action: Optional[str] = None,
85
85
  limit: int = 50,
86
86
  ) -> List[Dict[str, Any]]:
87
+ # SQLite treats a negative LIMIT as "no limit"; clamp (EX8).
88
+ try:
89
+ limit = max(1, min(1000, int(limit)))
90
+ except (TypeError, ValueError):
91
+ limit = 50
87
92
  conn = timeline_brain_db.connect(project_root)
88
93
  clauses: List[str] = []
89
94
  args: List[Any] = []
@@ -59,7 +59,7 @@ def _record_tool_result(
59
59
 
60
60
 
61
61
  def _run_ffmpeg(args: list[str]) -> None:
62
- subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", *args], check=True)
62
+ subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", *args], check=True, timeout=120)
63
63
 
64
64
 
65
65
  def _make_synthetic_video(work_dir: Path) -> Path:
@@ -59,7 +59,7 @@ def _record_tool_result(
59
59
 
60
60
 
61
61
  def _run_ffmpeg(args: list[str]) -> None:
62
- subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", *args], check=True)
62
+ subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", *args], check=True, timeout=120)
63
63
 
64
64
 
65
65
  def _make_synthetic_video(work_dir: Path) -> Path: