Skip to content

Commit 2ea3ea3

Browse files
committed
fix: handle gh security comments
1 parent c047d23 commit 2ea3ea3

4 files changed

Lines changed: 111 additions & 18 deletions

File tree

skills/multilingual-caption-video/SKILL.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ license: "GPL-3.0-or-Later"
55
compatibility: Requires ffmpeg and ffprobe with libass and H.264 support, uv, yt-dlp for URL inputs, and curl for URL delivery.
66
metadata:
77
author: o-az
8-
version: "1.1.0"
8+
version: "1.1.1"
99
---
1010

1111
# multilingual-caption-video
@@ -44,6 +44,7 @@ Run any script with `uv run <script> --help` for its interface.
4444
- Deliver only the generated MP4. A request for a URL authorizes uploading that generated file, not unrelated local files.
4545
- Write preferences only after explicit consent. An explicit request always overrides a saved preference for that job.
4646
- Delete only marked working directories created by `scripts/cleanup.py`.
47+
- Run bundled scripts as the current unprivileged user. Never invoke them through `sudo` or another privilege-elevation mechanism.
4748

4849
## Workflow
4950

@@ -55,7 +56,7 @@ Resolve the directory containing this `SKILL.md` as `SKILL_ROOT`, then inspect s
5556
uv run "$SKILL_ROOT/scripts/preferences.py" show
5657
```
5758

58-
Preferences live at `${XDG_CONFIG_HOME:-~/.config}/multilingual-caption-video/preferences.json`. The file may contain `delivery`, `language`, `font`, and `font_size`.
59+
Preferences live at `$XDG_CONFIG_HOME/multilingual-caption-video/preferences.json` when `XDG_CONFIG_HOME` is an absolute path, or `~/.config/multilingual-caption-video/preferences.json` otherwise. The file may contain `delivery`, `language`, `font`, and `font_size`.
5960

6061
Resolve each setting in this order: the current request, a saved preference, then the documented default. If neither the request nor saved preferences specify `delivery`, ask whether the user wants the finished video as a file or URL. If neither specifies the target language, ask for it. Do not infer either choice.
6162

skills/multilingual-caption-video/scripts/cleanup.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,7 @@ def main() -> None:
7272
description="Create and safely clean caption-video work directories."
7373
)
7474
subparsers = parser.add_subparsers(dest="command", required=True)
75-
create_parser = subparsers.add_parser("create")
76-
create_parser.add_argument("--parent", type=Path)
75+
subparsers.add_parser("create")
7776
schedule_parser = subparsers.add_parser("schedule")
7877
schedule_parser.add_argument("path", type=Path)
7978
schedule_parser.add_argument("--delay", type=float, default=300)
@@ -85,7 +84,7 @@ def main() -> None:
8584
args = parser.parse_args()
8685

8786
if args.command == "create":
88-
print(create_workdir(args.parent))
87+
print(create_workdir())
8988
elif args.command == "schedule":
9089
print(
9190
json.dumps(

skills/multilingual-caption-video/scripts/preferences.py

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import argparse
44
import json
55
import os
6+
import tempfile
67
from collections.abc import Mapping
78
from pathlib import Path
89
from typing import cast
@@ -12,9 +13,11 @@
1213

1314

1415
def default_preferences_path() -> Path:
15-
config_home = Path(
16-
os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")
17-
)
16+
default_config_home = Path.home() / ".config"
17+
configured = os.environ.get("XDG_CONFIG_HOME")
18+
config_home = Path(configured) if configured else default_config_home
19+
if not config_home.is_absolute():
20+
config_home = default_config_home
1821
return config_home / "multilingual-caption-video" / "preferences.json"
1922

2023

@@ -45,23 +48,36 @@ def validate_preferences(preferences: Mapping[str, object]) -> Preferences:
4548

4649
def load_preferences(path: Path | None = None) -> Preferences:
4750
path = path or default_preferences_path()
48-
if not path.exists():
51+
try:
52+
serialized = path.read_text(encoding="utf-8")
53+
except FileNotFoundError:
4954
return {}
50-
return validate_preferences(json.loads(path.read_text(encoding="utf-8")))
55+
return validate_preferences(json.loads(serialized))
5156

5257

5358
def save_preferences(
5459
updates: Mapping[str, object], path: Path | None = None
5560
) -> Preferences:
5661
path = path or default_preferences_path()
5762
preferences = validate_preferences({**load_preferences(path), **updates})
58-
path.parent.mkdir(parents=True, exist_ok=True)
59-
temporary_path = path.with_suffix(".tmp")
60-
temporary_path.write_text(
61-
f"{json.dumps(preferences, indent=2, ensure_ascii=False, sort_keys=True)}\n",
62-
encoding="utf-8",
63-
)
64-
os.replace(temporary_path, path)
63+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
64+
serialized = f"{json.dumps(preferences, indent=2, ensure_ascii=False, sort_keys=True)}\n"
65+
temporary_path: Path | None = None
66+
try:
67+
with tempfile.NamedTemporaryFile(
68+
mode="w",
69+
encoding="utf-8",
70+
dir=path.parent,
71+
prefix=f".{path.name}.",
72+
suffix=".tmp",
73+
delete=False,
74+
) as temporary_file:
75+
temporary_path = Path(temporary_file.name)
76+
temporary_file.write(serialized)
77+
os.replace(temporary_path, path)
78+
finally:
79+
if temporary_path is not None:
80+
temporary_path.unlink(missing_ok=True)
6581
return preferences
6682

6783

tests/multilingual-caption-video/test_caption_video.py

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
#!/usr/bin/env -S uv run
22

33
import json
4+
import os
5+
import subprocess
46
import sys
57
import tempfile
68
import time
79
from pathlib import Path
10+
from unittest.mock import patch
811

912
SCRIPTS = (
1013
Path(__file__).parents[2]
@@ -17,7 +20,11 @@
1720

1821
from cleanup import create_workdir, keep_workdir, schedule_cleanup
1922
from make_ass import build_ass
20-
from preferences import load_preferences, save_preferences
23+
from preferences import (
24+
default_preferences_path,
25+
load_preferences,
26+
save_preferences,
27+
)
2128
from transcribe import transcript_payload
2229

2330

@@ -53,6 +60,48 @@ def __init__(self, start: float, end: float, text: str) -> None:
5360
assert "Style: Default,Noto Naskh Arabic UI,35," in ass
5461
assert "Dialogue: 0,0:00:01.25,0:00:03.50" in ass
5562

63+
cleanup_script = SCRIPTS / "cleanup.py"
64+
with tempfile.TemporaryDirectory() as temporary_directory:
65+
unsupported_parent = subprocess.run(
66+
[
67+
sys.executable,
68+
str(cleanup_script),
69+
"create",
70+
"--parent",
71+
temporary_directory,
72+
],
73+
capture_output=True,
74+
text=True,
75+
check=False,
76+
)
77+
assert unsupported_parent.returncode != 0
78+
assert "unrecognized arguments: --parent" in unsupported_parent.stderr
79+
80+
original_xdg_config_home = os.environ.get("XDG_CONFIG_HOME")
81+
try:
82+
with tempfile.TemporaryDirectory() as temporary_directory:
83+
absolute_config_home = Path(temporary_directory)
84+
os.environ["XDG_CONFIG_HOME"] = str(absolute_config_home)
85+
assert default_preferences_path() == (
86+
absolute_config_home
87+
/ "multilingual-caption-video"
88+
/ "preferences.json"
89+
)
90+
91+
for invalid_config_home in ("", "relative/config"):
92+
os.environ["XDG_CONFIG_HOME"] = invalid_config_home
93+
assert default_preferences_path() == (
94+
Path.home()
95+
/ ".config"
96+
/ "multilingual-caption-video"
97+
/ "preferences.json"
98+
)
99+
finally:
100+
if original_xdg_config_home is None:
101+
os.environ.pop("XDG_CONFIG_HOME", None)
102+
else:
103+
os.environ["XDG_CONFIG_HOME"] = original_xdg_config_home
104+
56105
with tempfile.TemporaryDirectory() as temporary_directory:
57106
preferences_path = Path(temporary_directory) / "preferences.json"
58107
assert load_preferences(preferences_path) == {}
@@ -70,6 +119,34 @@ def __init__(self, start: float, end: float, text: str) -> None:
70119
else:
71120
raise AssertionError("Unsupported delivery preference should fail")
72121

122+
victim_path = Path(temporary_directory) / "victim.txt"
123+
victim_path.write_text("unchanged\n")
124+
predictable_temporary_path = preferences_path.with_suffix(".tmp")
125+
predictable_temporary_path.symlink_to(victim_path)
126+
save_preferences({"language": "Spanish"}, preferences_path)
127+
assert victim_path.read_text() == "unchanged\n"
128+
assert predictable_temporary_path.is_symlink()
129+
130+
destination_target = Path(temporary_directory) / "destination-target.json"
131+
destination_target.write_text('{"language": "English"}\n')
132+
preferences_path.unlink()
133+
preferences_path.symlink_to(destination_target)
134+
save_preferences({"language": "Arabic"}, preferences_path)
135+
assert not preferences_path.is_symlink()
136+
assert destination_target.read_text() == '{"language": "English"}\n'
137+
138+
failed_preferences_path = (
139+
Path(temporary_directory) / "failed" / "preferences.json"
140+
)
141+
with patch("preferences.os.replace", side_effect=OSError("replace failed")):
142+
try:
143+
save_preferences({"language": "French"}, failed_preferences_path)
144+
except OSError as error:
145+
assert str(error) == "replace failed"
146+
else:
147+
raise AssertionError("A failed preferences replacement should fail")
148+
assert list(failed_preferences_path.parent.glob("*.tmp")) == []
149+
73150
cleanup_root = Path(temporary_directory) / "cleanup"
74151
cleanup_root.mkdir()
75152

0 commit comments

Comments
 (0)