Răsfoiți Sursa

security: 安全自述+云能力隔离——SECURITY.md穷举数据流, cloud/拆分, 同意门, 三处瑕疵修复

回应社区安全审查反馈(外部agent审查曾拒装):
- 新增SECURITY.md:穷举全部出站域名/密钥边界/删除边界/子进程,供审查器逐条核验
- tts-doubao.mjs、ai-review-video.py移入scripts/cloud/,标注为可选云能力
- 外发前显式同意门:--yes或HUASHU_CLOUD_OK=1,否则打印数据流向并exit 2
- 修复:ai-review-video.py删除作者个人路径硬编码,只从skill根.env提取ARK_API_KEY单变量
- 修复:TTS endpoint强制*.bytedance.com/*.volces.com域名白名单,防.env篡改重定向
- 修复:narrate-pipeline.mjs两处execSync路径拼接改execFileSync参数数组
- 显式化:react-setup.md选项B标记仅限本地演示;hyperframes init装19个skill加预警;
  design-gate-hook.sh声明不会自动安装;README中英各加安全章节

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
alchain 1 lună în urmă
părinte
comite
3b1d873575

+ 4 - 0
.env.example

@@ -4,3 +4,7 @@ DOUBAO_TTS_API_KEY=your_api_key_here
 DOUBAO_TTS_VOICE_ID=your_clone_voice_id_here
 DOUBAO_TTS_CLUSTER=volcano_icl
 DOUBAO_TTS_ENDPOINT=https://openspeech.bytedance.com/api/v1/tts
+
+# AI看片评审(可选云能力,scripts/cloud/ai-review-video.py)
+# 火山方舟 API Key,申请地址:https://console.volcengine.com/ark
+ARK_API_KEY=your_ark_api_key_here

+ 1 - 0
.gitignore

@@ -35,3 +35,4 @@ _style-research/
 
 # Demo 大视频(用 GitHub Releases 分发,不进仓库膨胀)
 demos/**/*.mp4
+__pycache__/

+ 6 - 0
README.en.md

@@ -237,6 +237,12 @@ Claude Design is a **better graphics tool**. Huashu-design makes **the graphics-
 
 ---
 
+## Security & Data Flow
+
+The core pipeline (design → render → MP4/PDF/PPTX export) runs **100% locally — zero network calls, zero API keys**. Cloud features (Doubao TTS narration, AI video review) are isolated in `scripts/cloud/`, fully optional: your own keys, official vendor APIs only, and an explicit `--yes` consent gate before anything leaves your machine. No telemetry; nothing is ever sent to any author-controlled server. Every outbound host, credential touchpoint, and deletion boundary is exhaustively declared in [SECURITY.md](SECURITY.md) — point your agent at it and verify against the code.
+
+---
+
 ## Limitations
 
 - **No layer-editable PPTX-to-Figma round-trip.** The output is HTML — screenshottable, recordable, image-exportable, but not draggable into Keynote for text-position tweaks.

+ 6 - 0
README.md

@@ -268,6 +268,12 @@ Claude Design 是**更好的图形工具**,huashu-design 是**让图形工具
 
 ---
 
+## 安全与数据流
+
+核心链路(设计→渲染→MP4/PDF/PPTX导出)**100%本地运行,零网络零key**。云能力(豆包TTS配音、AI看片评审)全部隔离在 `scripts/cloud/`,完全可选:用你自己的key、只发对应厂商官方API、首次调用需 `--yes` 显式确认。无telemetry,没有任何数据发往作者服务器。全部出站域名、密钥处理、删除边界的穷举声明见 [SECURITY.md](SECURITY.md),欢迎用你的agent对着代码逐条核验。
+
+---
+
 ## Limitations
 
 - **不支持图层级可编辑的 PPTX 到 Figma**。产出 HTML,可截图、录屏、导图,但不能拖进 Keynote 改文字位置。

+ 52 - 0
SECURITY.md

@@ -0,0 +1,52 @@
+# Security & Data-Flow Statement
+
+This document exhaustively declares every network destination, credential touchpoint, subprocess, and deletion this skill can perform, so that users and automated security reviewers can verify the claims against the code.
+
+**TL;DR: the core pipeline (HTML design → render → MP4/PDF/PPTX export) runs 100% locally with zero network calls and zero API keys. All cloud-touching code is isolated in `scripts/cloud/`, is strictly optional, uses only keys you supply, sends data only to the corresponding vendor's official API, and refuses to run without explicit consent (`--yes` flag or `HUASHU_CLOUD_OK=1`). There is no telemetry. No data is ever sent to any server controlled by the skill author.**
+
+## Complete list of network destinations
+
+| Host | Where | What is sent | When |
+|---|---|---|---|
+| `ark.cn-beijing.volces.com` (Volcengine Ark, ByteDance official API) | `scripts/cloud/ai-review-video.py` | Compressed segments of **your own rendered video**, for AI quality review, authenticated with **your own** `ARK_API_KEY` | Only when you run it, and only after the consent gate |
+| `openspeech.bytedance.com` (ByteDance official TTS API) | `scripts/cloud/tts-doubao.mjs` (also invoked by `scripts/narrate-pipeline.mjs`) | The narration text you want synthesized, with **your own** key. The endpoint is validated against a hardcoded hostname allowlist (`*.bytedance.com` / `*.volces.com`) — a tampered `.env` cannot redirect your key or text elsewhere | Only when you run it, and only after the consent gate |
+| `commons.wikimedia.org` (official Wikimedia API) | `scripts/fetch_images.py` | Image search keywords; downloads CC/public-domain images with license info printed for review | Only when the agent fetches stock imagery for a content design |
+| Brand official websites, `simpleicons.org`, Google favicon service | `references/brand-asset-protocol.md` (instructions, no script) | Plain GET requests to download publicly served logos/brand assets | Only when you ask for a brand-specific design |
+| `fonts.googleapis.com`, `unpkg.com` and similar CDNs | Static `<link>`/`<script>` tags inside demo/output HTML | Standard browser font/library fetches when *you* open a generated HTML file | Browser-side only; render scripts work offline-first |
+
+That is the entire list. `grep -rn "https://" --include="*.py" --include="*.mjs" --include="*.js" --include="*.sh" scripts/` to verify.
+
+## API keys
+
+- No key is hardcoded anywhere; the repo ships only `.env.example` placeholders (`.env` is gitignored).
+- Keys are read from the **skill's own root `.env`** or process environment — never from files elsewhere on your machine. `ai-review-video.py` extracts only the single `ARK_API_KEY` variable; it does not load the rest of the file into the environment.
+- Keys are transmitted exclusively to the corresponding vendor's official endpoint listed above, over HTTPS, as auth headers.
+- `references/react-setup.md` option B (pasting an Anthropic key into a demo page input) is explicitly marked local-demo-only and not recommended; the default options require no key at all.
+
+## Explicit consent gate
+
+Both cloud scripts print exactly what will be sent to which host and exit before any network call unless you pass `--yes` or set `HUASHU_CLOUD_OK=1`. Everything else in this skill never needs the gate because it never leaves your machine.
+
+## Subprocesses
+
+All subprocess calls invoke local media tools only: `ffmpeg`, `ffprobe`, `ffplay`, Playwright/Chromium for HTML rendering and screenshots. No shell-to-network combinations, no curl-pipe-sh patterns.
+
+## File deletion
+
+Recursive deletion is limited to temp directories the scripts themselves create with unique timestamp+PID names (`.video-tmp-*`, `.seek-tmp-*`, `_narration/.tmp`, Python `tempfile.TemporaryDirectory`). No script ever deletes user data or anything outside its own scratch space.
+
+## Dependencies
+
+Mainstream registry packages only (`playwright`, `sharp`, `pptxgenjs`, `pdf-lib`, `requests`), installed via standard `npm`/`pip`/`uv` — no binary downloads from arbitrary URLs. One documented exception to be aware of: `npx hyperframes init` (optional animation backend, see `references/hyperframes-backend.md`) installs 19 hyperframes documentation skills into `~/.claude/skills/`. This is called out with a warning in the docs before the command.
+
+## Hooks
+
+`scripts/design-gate-hook.sh` is **never installed automatically** — nothing in this skill writes to `settings.json`. If you manually opt in, its entire behavior is: block long-video render commands (exit 2) until a design-approval file exists. It makes no network calls, writes nothing, deletes nothing.
+
+## Proxy handling note
+
+`fetch_images.py` and `ai-review-video.py` disable inheriting proxy environment variables (`trust_env = False` / clearing `ALL_PROXY` etc.) for their own requests. This exists to survive stale local proxy configurations that break TLS — not to evade monitoring. If you need these requests to go through your proxy, set it explicitly in the script invocation.
+
+## Reporting
+
+Found something that contradicts this document? Please open an issue — a mismatch between this file and the code is treated as a bug.

+ 2 - 2
SKILL.md

@@ -348,7 +348,7 @@ description: 花叔Design——用HTML做高保真原型、幻灯片、动画、
    - SFX 按 `references/audio-design-rules.md` 设计 cue 清单(时间轴 + 音效类型),用 `assets/sfx/<category>/*.mp3` 37 个预制资源,按配方 A/B/C/D 选密度(发布 hero ≈ 6个/10s,工具演示 ≈ 0-2个/10s)
    - **BGM + SFX 双轨制必须同时做**——只做 BGM 是 ⅓ 分完成度;SFX 占高频、BGM 占低频,频段隔离见 audio-design-rules.md 的 ffmpeg 模板
    - 交付前 `ffprobe -select_streams a` 确认有 audio stream,没有则不是成品
-   - **(终渲后)AI看片评审**:`python3 scripts/ai-review-video.py <成片> [导演稿.md]` 出结构化报告(黑帧/死段/hero贯穿/过渡类型/音效空打),流程与局限见 `references/ai-video-review.md`
+   - **(终渲后)AI看片评审**(可选云能力,自备key+显式确认,见SECURITY.md):`uv run scripts/cloud/ai-review-video.py --video <成片> --context 导演稿.md --yes` 出结构化报告(黑帧/死段/hero贯穿/过渡类型/音效空打),流程与局限见 `references/ai-video-review.md`;无key时用 `scripts/verify-video.sh` 截帧人工看
    - **跳过音频的条件**:用户明确说「不要音频」「纯画面」「我要自己配音」——否则默认带。
    - 参考完整流程见 `references/video-export.md` + `references/audio-design-rules.md` + `references/sfx-library.md`。
 9.5. **(带解说时走这条)解说驱动动画 · L2 长概念视频**:用户要做「5-20 分钟解释一个概念」、「带配音的教程」、「长篇科普视频」时——**不要先做动画再配音**,那会让画面节奏跟解说对不上。改走 `references/voiceover-pipeline.md` 的解说驱动流程:
@@ -472,7 +472,7 @@ description: 花叔Design——用HTML做高保真原型、幻灯片、动画、
 | **HyperFrames 渲染后端**(新动画默认;选型边界/合成契约/老demo迁移/check流程) | `references/hyperframes-backend.md` |
 | **设计语言的 GSAP 实现配方**(easing 映射/运动语言8条/五段叙事骨架/seek 安全规则) | `references/gsap-recipes.md` |
 | **动画的正向设计语法**(Anthropic 级叙事/运动/节奏/表达风格)| `references/animation-best-practices.md`(5 段叙事+Expo easing+运动语言 8 条+3 种场景配方)|
-| **带解说的长动画 / 长概念视频**(5-20 分钟带配音、解说驱动画面、TTS 实测时长生成 timeline)| `references/voiceover-pipeline.md`(铁律:连续运动叙事、禁 PowerPoint 切换)+ `assets/narration_stage.jsx` + `scripts/{tts-doubao,narrate-pipeline}.mjs` + `scripts/{mix-voiceover,render-narration}.sh` |
+| **带解说的长动画 / 长概念视频**(5-20 分钟带配音、解说驱动画面、TTS 实测时长生成 timeline)| `references/voiceover-pipeline.md`(铁律:连续运动叙事、禁 PowerPoint 切换)+ `assets/narration_stage.jsx` + `scripts/cloud/tts-doubao.mjs`(可选云TTS,自备key,见SECURITY.md)+ `scripts/narrate-pipeline.mjs` + `scripts/{mix-voiceover,render-narration}.sh` |
 | 做Tweaks实时调参 | `references/tweaks-system.md` |
 | 没有design context怎么办 | `references/design-context.md`(薄 fallback) 或 `references/design-styles.md`(厚 fallback:HTML 原生 40 种风格库,网页 20+PPT 20,按温度分级) |
 | **需求模糊要推荐风格方向** | `references/design-styles.md`(40 种 HTML 原生风格库,含还原度/温度/开源字体)+ `assets/showcases/INDEX.md`(预制截图画廊) |

+ 9 - 3
references/ai-video-review.md

@@ -1,7 +1,10 @@
-# AI看片评审闭环(ai-review-video.py)
+# AI看片评审闭环(scripts/cloud/ai-review-video.py)
 
 > 终渲MP4喂给视频理解模型(seed-2.0-lite),按固定checklist出结构化评审报告。
 > 定位:**终渲后、交付前**的最后一道质检,替代人肉全片重看。不替代逐帧verify-video.sh。
+> ⚠️ 可选云能力:压缩后的视频段会发送到火山方舟官方接口(ark.cn-beijing.volces.com),
+> 使用你自己的ARK_API_KEY,需`--yes`或`HUASHU_CLOUD_OK=1`显式确认。见仓库根`SECURITY.md`。
+> 不想用云:`scripts/verify-video.sh`截帧人工看,全程本地。
 
 ## 何时用
 
@@ -14,11 +17,14 @@
 
 ```bash
 cd 项目目录 && unset ALL_PROXY   # 脚本内已免疫代理,unset是双保险
-uv run ~/.claude/skills/huashu-design/scripts/ai-review-video.py \
+uv run ~/.claude/skills/huashu-design/scripts/cloud/ai-review-video.py \
   --video 成片.mp4 \
-  --context 导演稿.md        # 强烈建议带上:模型靠它区分「设计意图」和「bug」
+  --context 导演稿.md \      # 强烈建议带上:模型靠它区分「设计意图」和「bug」
+  --yes                      # 确认视频段发送火山方舟(或 HUASHU_CLOUD_OK=1)
 ```
 
+- ARK_API_KEY 配在 skill 根目录 `.env`(已 gitignore)或环境变量,脚本只提取这一个变量
+
 - 报告落盘:视频同目录 `<视频名>-AI评审.md`(`--output`可改)
 - `--segment-len` 默认60秒一段;`--model` 默认 doubao-seed-2-0-lite-260215
 - 210秒片实测:6次API调用,6-10分钟,tokens约18万in/2万out(lite档,费用分钱级)

+ 4 - 0
references/hyperframes-backend.md

@@ -19,6 +19,10 @@
 
 ## 项目脚手架
 
+> ⚠️ 安装预警:`hyperframes init` 除了生成项目文件,还会把 **19 个 hyperframes skill 安装到
+> `~/.claude/skills/`**(渲染后端的合成契约文档,纯文档无可执行 hook)。介意的话先跑
+> `npx hyperframes docs` 看本地文档清单再决定是否 init。
+
 ```bash
 npx -y hyperframes init 项目名 --example blank   # 非交互必须带 --example
 cd 项目名 && npm install

+ 5 - 1
references/react-setup.md

@@ -137,10 +137,14 @@ window.claude = {
 };
 ```
 
-### 选项B:真调Anthropic API
+### 选项B:真调Anthropic API(不推荐,仅限本地演示)
 
 需要API key,用户必须在HTML里填入自己的key才能跑。**永远不要把key硬编码在HTML里**。
 
+⚠️ 安全边界:这个方案只适合本地`file://`打开、用完即关的演示。key会留在DOM/内存里——
+**不要部署这个页面、不要带着填了key的页面截图/录屏传播**。生产场景一律走本地proxy后端转发,
+浏览器端不碰key。默认优先选项A/C(完全不需要key)。
+
 ```html
 <input id="api-key" placeholder="粘贴你的Anthropic API key" />
 <script>

+ 6 - 2
references/voiceover-pipeline.md

@@ -360,13 +360,17 @@ NarrationStage 自动检测 `window.__recording`:
 
 | 脚本 | 输入 | 输出 |
 |---|---|---|
-| `scripts/tts-doubao.mjs` | 单段文本 | 单个 mp3 + 实测时长 |
+| `scripts/cloud/tts-doubao.mjs` | 单段文本 | 单个 mp3 + 实测时长 |
 | `scripts/narrate-pipeline.mjs` | 解说稿 .md | voiceover.mp3 + timeline.json |
 | `scripts/mix-voiceover.sh` | 视频 + voiceover.mp3 [+ BGM] | 带音频的 MP4 |
 | `scripts/render-narration.sh` | 解说 HTML + timeline.json | 最终 MP4(录制 + 混音一条龙)|
 
 ## .env 配置
 
+> ⚠️ TTS 是可选云能力:解说稿文本会发送到豆包 TTS 官方接口(openspeech.bytedance.com),
+> 使用你自己的 key。脚本首次调用需 `--yes` 或 `HUASHU_CLOUD_OK=1` 显式确认,
+> endpoint 强制校验字节官方域名白名单。数据流向声明见仓库根 `SECURITY.md`。
+
 skill 根目录下 `.env`(已 gitignore):
 
 ```
@@ -388,7 +392,7 @@ DOUBAO_TTS_VOICE_ID=zh_female_xiaohe_uranus_bigtts
 ## 标准工作流(10 步)
 
 1. **写解说稿**:解说稿是源代码。先把整段口播写完整,标段标题 `## scene-id`,关键句前加 `[[cue:xx]]`
-2. **跑 narrate-pipeline**:`node scripts/narrate-pipeline.mjs --script script.md --out-dir _narration`
+2. **跑 narrate-pipeline**:`node scripts/narrate-pipeline.mjs --script script.md --out-dir _narration --yes`(`--yes`=确认文本发送豆包TTS)
 3. **听整段 voiceover.mp3**:节奏不对回去改稿。**这一步决定整片质量上限**
 4. **🛑 设计前先回答铁律**:hero element 是什么?它在每段是什么状态?跨场景怎么 morph?答不上不要写代码
 5. **写动画 HTML**:用 NarrationStage + 一个或几个 hero element 跨 scene 演戏

+ 27 - 18
scripts/ai-review-video.py → scripts/cloud/ai-review-video.py

@@ -9,10 +9,14 @@
 AI看片评审闭环 —— 渲染出的动画MP4喂给视频理解模型(seed-2.0-lite),
 按固定checklist逐段送审 + 全片低清扫一遍,汇总成结构化markdown评审报告。
 
+⚠️ 可选云能力:会把压缩后的成片片段发送到火山方舟官方接口(ark.cn-beijing.volces.com)
+做视频理解评审,使用你自己的 ARK_API_KEY。首次调用需 --yes 或 HUASHU_CLOUD_OK=1
+显式确认。数据流向声明见仓库根 SECURITY.md。本地免费替代:scripts/verify-video.sh 截帧人工看。
+
 Usage:
-    uv run ai-review-video.py --video 成片.mp4
-    uv run ai-review-video.py --video 成片.mp4 --context 导演稿.md
-    uv run ai-review-video.py --video 成片.mp4 --segment-len 60 --output 报告.md
+    uv run ai-review-video.py --video 成片.mp4 --yes
+    uv run ai-review-video.py --video 成片.mp4 --context 导演稿.md --yes
+    uv run ai-review-video.py --video 成片.mp4 --segment-len 60 --output 报告.md --yes
 
 调用链路:
     1. ffprobe 探测时长/音轨
@@ -23,8 +27,8 @@ Usage:
     5. 全片再压一版低清(960宽/10fps)单独送审,专查跨段叙事连贯/hero贯穿
     6. 文本汇总call:按checklist逐项合并,产出最终报告;分段原始发现保留在附录
 
-API key:只从 .env 读 ARK_API_KEY(写作/.env),绝不硬编码。
-代理:requests session 关闭 trust_env,免疫 ALL_PROXY 之类的历史坑
+API key:优先读环境变量 ARK_API_KEY,其次读 skill 根目录 .env(只提取这一个变量),绝不硬编码。
+代理:requests session 关闭 trust_env(不继承本机代理配置),免疫 ALL_PROXY 之类残留代理导致的 TLS 报错
 """
 
 import argparse
@@ -42,10 +46,7 @@ import requests
 
 API_URL = "https://ark.cn-beijing.volces.com/api/v3/responses"
 DEFAULT_MODEL = "doubao-seed-2-0-lite-260215"
-ENV_PATHS = [
-    Path("/Users/alchain/Documents/写作/.env"),
-    Path(__file__).resolve().parents[4] / "Documents/写作/.env",
-]
+ENV_PATH = Path(__file__).resolve().parents[2] / ".env"  # skill 根目录 .env(已 gitignore)
 MAX_SEGMENT_MB = 8  # 单段压缩产物超过这个值就再压一档
 
 CHECKLIST = """\
@@ -75,17 +76,16 @@ def log(msg):
 
 
 def load_api_key():
-    for p in ENV_PATHS:
-        if p.exists():
-            for line in p.read_text(encoding="utf-8").splitlines():
-                line = line.strip()
-                if line and not line.startswith("#") and "=" in line:
-                    k, v = line.split("=", 1)
-                    os.environ.setdefault(k.strip(), v.strip())
-            break
     key = os.getenv("ARK_API_KEY")
+    if not key and ENV_PATH.exists():
+        # 只提取 ARK_API_KEY 一个变量,不把 .env 整文件灌进环境
+        for line in ENV_PATH.read_text(encoding="utf-8").splitlines():
+            line = line.strip()
+            if line.startswith("ARK_API_KEY") and "=" in line:
+                key = line.split("=", 1)[1].strip().strip("'\"")
+                break
     if not key or key.startswith("your_"):
-        sys.exit("Error: ARK_API_KEY 未配置(写作/.env),拒绝继续。不编造评审结果。")
+        sys.exit("Error: ARK_API_KEY 未配置(skill 根目录 .env 或环境变量),拒绝继续。不编造评审结果。")
     return key
 
 
@@ -258,11 +258,20 @@ def main():
     ap.add_argument("--segment-len", type=int, default=60, help="分段长度秒(默认60)")
     ap.add_argument("--model", default=DEFAULT_MODEL, help=f"模型(默认{DEFAULT_MODEL})")
     ap.add_argument("--output", "-o", help="报告路径(默认视频同目录<视频名>-AI评审.md)")
+    ap.add_argument("--yes", action="store_true",
+                    help="确认将压缩后的视频段发送到火山方舟官方接口(或设 HUASHU_CLOUD_OK=1)")
     args = ap.parse_args()
 
     video = Path(args.video).resolve()
     if not video.exists():
         sys.exit(f"Error: 视频不存在 {video}")
+
+    if not args.yes and os.getenv("HUASHU_CLOUD_OK") != "1":
+        sys.exit(
+            f"[云能力确认] 本次将把 {video.name} 压缩后分段发送到 ark.cn-beijing.volces.com"
+            "(火山方舟官方接口,使用你自己的 ARK_API_KEY 做视频理解评审)。\n"
+            "确认无误请重跑并加 --yes,或设置环境变量 HUASHU_CLOUD_OK=1。"
+            "数据流向声明见 SECURITY.md;本地免费替代:scripts/verify-video.sh。")
     out_path = Path(args.output) if args.output else video.parent / f"{video.stem}-AI评审.md"
 
     context_text = ""

+ 26 - 4
scripts/tts-doubao.mjs → scripts/cloud/tts-doubao.mjs

@@ -2,10 +2,14 @@
 /**
  * tts-doubao.mjs · 豆包语音 TTS(火山引擎 openspeech)
  *
+ * ⚠️ 可选云能力:本脚本会把待配音文本发送到字节跳动官方 TTS 接口(openspeech.bytedance.com),
+ * 使用你自己的 key,endpoint 强制校验域名白名单。首次调用需 --yes 或 HUASHU_CLOUD_OK=1
+ * 显式确认。数据流向声明见仓库根 SECURITY.md。
+ *
  * 用法:
- *   node scripts/tts-doubao.mjs --text "你好" --out demo.mp3
- *   node scripts/tts-doubao.mjs --text-file script.txt --out out.mp3 --speed 1.0
- *   node scripts/tts-doubao.mjs --text "你好" --out demo.mp3 --timestamps   # 附带字级时间戳
+ *   node scripts/cloud/tts-doubao.mjs --text "你好" --out demo.mp3 --yes
+ *   node scripts/cloud/tts-doubao.mjs --text-file script.txt --out out.mp3 --speed 1.0 --yes
+ *   node scripts/cloud/tts-doubao.mjs --text "你好" --out demo.mp3 --timestamps --yes   # 附带字级时间戳
  *
  * 输出:
  *   - mp3 文件写到 --out 路径
@@ -32,7 +36,7 @@ import { fileURLToPath } from 'node:url';
 import { randomUUID } from 'node:crypto';
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
-const SKILL_ROOT = path.resolve(__dirname, '..');
+const SKILL_ROOT = path.resolve(__dirname, '..', '..');
 
 function loadEnv() {
   const envPath = path.join(SKILL_ROOT, '.env');
@@ -64,6 +68,7 @@ function parseArgs(argv) {
     else if (a === '--voice') args.voice = argv[++i];
     else if (a === '--encoding') args.encoding = argv[++i];
     else if (a === '--timestamps') args.timestamps = true;
+    else if (a === '--yes') args.yes = true;
     else if (a === '--help' || a === '-h') args.help = true;
   }
   return args;
@@ -80,6 +85,7 @@ tts-doubao.mjs · 豆包语音 TTS
   --voice <voice_id>    覆盖 .env 里的音色 id
   --encoding <ext>      mp3 / wav / pcm,默认 mp3
   --timestamps          请求字级时间戳(enable_subtitle),结果 JSON 多一个 words 数组
+  --yes                 确认将文本发送到豆包 TTS 官方接口(或设 HUASHU_CLOUD_OK=1)
 `.trim());
   process.exit(1);
 }
@@ -182,8 +188,15 @@ async function readV3Audio(res) {
   return { audio: Buffer.concat(chunks), words };
 }
 
+// endpoint 域名白名单:key 和文本只允许发往字节官方域名,防 .env 被篡改后重定向
+const ALLOWED_ENDPOINT_HOSTS = /(^|\.)(bytedance\.com|volces\.com)$/;
+
 async function tts({ text, voice, speed, encoding, timestamps }) {
   const endpoint = process.env.DOUBAO_TTS_ENDPOINT || 'https://openspeech.bytedance.com/api/v3/tts/unidirectional';
+  const host = new URL(endpoint).hostname;
+  if (!ALLOWED_ENDPOINT_HOSTS.test(host)) {
+    throw new Error(`DOUBAO_TTS_ENDPOINT 域名 ${host} 不在白名单(*.bytedance.com / *.volces.com),拒绝发送`);
+  }
   const voiceId = voice || process.env.DOUBAO_TTS_VOICE_ID || process.env.DOUBAO_SPEAKER;
   const resourceId = process.env.DOUBAO_TTS_RESOURCE_ID || inferResourceId(voiceId || '');
   const requestId = randomUUID();
@@ -236,6 +249,15 @@ async function main() {
     usage();
   }
 
+  if (!args.yes && process.env.HUASHU_CLOUD_OK !== '1') {
+    const host = new URL(process.env.DOUBAO_TTS_ENDPOINT || 'https://openspeech.bytedance.com').hostname;
+    console.error(
+      `[云能力确认] 本次将把约${text.length}字文本发送到 ${host}(豆包TTS官方接口,使用你自己的key合成语音)。\n` +
+      `确认无误请重跑并加 --yes,或设置环境变量 HUASHU_CLOUD_OK=1。数据流向声明见 SECURITY.md。`,
+    );
+    process.exit(2);
+  }
+
   const outPath = path.resolve(args.out);
   fs.mkdirSync(path.dirname(outPath), { recursive: true });
 

+ 4 - 0
scripts/design-gate-hook.sh

@@ -11,6 +11,10 @@
 #   - 项目目录(或上两级)存在 direction-approved.md
 #   - 命令里显式带 SKIP_DESIGN_GATE=1(花叔明说跳过时用,可审计)
 #
+# 安全声明:本hook**不会被skill自动安装**——SKILL.md/README没有任何写入settings.json的
+# 指令,只有你手动把它配进settings.json后才生效。行为上限:对匹配的长片渲染命令exit 2
+# 阻止执行并打印原因,无网络请求、无文件写入、无删除。见仓库根SECURITY.md。
+#
 # settings.json配置:PreToolUse / matcher "Bash" / command指向本脚本
 
 INPUT=$(cat)

+ 22 - 7
scripts/narrate-pipeline.mjs

@@ -35,12 +35,12 @@
 
 import fs from 'node:fs';
 import path from 'node:path';
-import { execFileSync, execSync } from 'node:child_process';
+import { execFileSync } from 'node:child_process';
 import { fileURLToPath } from 'node:url';
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
 const SKILL_ROOT = path.resolve(__dirname, '..');
-const TTS_SCRIPT = path.join(__dirname, 'tts-doubao.mjs');
+const TTS_SCRIPT = path.join(__dirname, 'cloud', 'tts-doubao.mjs');
 
 function parseArgs(argv) {
   const args = {};
@@ -49,6 +49,7 @@ function parseArgs(argv) {
     if (a === '--script') args.script = argv[++i];
     else if (a === '--out-dir') args.outDir = argv[++i];
     else if (a === '--no-timestamps') args.noTimestamps = true;
+    else if (a === '--yes') args.yes = true;
     else if (a === '--help' || a === '-h') args.help = true;
   }
   return args;
@@ -61,6 +62,7 @@ narrate-pipeline.mjs · L2 长解说总指挥
   --script <path>     解说稿 .md 文件(必填)
   --out-dir <path>    输出目录(必填)
   --no-timestamps     不请求字级时间戳(默认请求,chunks 里带 words 供卡拉OK字幕)
+  --yes               确认将解说稿文本发送到豆包 TTS 官方接口(或设 HUASHU_CLOUD_OK=1)
 
 输出:<out-dir>/voiceover.mp3 + <out-dir>/timeline.json
 `.trim());
@@ -135,7 +137,8 @@ function getDuration(filePath) {
 let timestampsBroken = false; // 时间戳请求失败一次后,后续 chunk 全部降级,避免反复重试
 
 function callTTS(text, outPath, opts) {
-  const args = ['--text', text, '--out', outPath];
+  // 同意门已在本管线入口过(见 main),子进程直接带 --yes
+  const args = ['--text', text, '--out', outPath, '--yes'];
   if (opts.voice) args.push('--voice', opts.voice);
   if (opts.speed) args.push('--speed', String(opts.speed));
   const wantTimestamps = opts.timestamps && !timestampsBroken;
@@ -166,16 +169,19 @@ function ffmpegConcat(inputs, output) {
     listFile,
     inputs.map((p) => `file '${p.replace(/'/g, "'\\''")}'`).join('\n'),
   );
-  execSync(
-    `ffmpeg -y -f concat -safe 0 -i "${listFile}" -c copy "${output}"`,
+  execFileSync(
+    'ffmpeg',
+    ['-y', '-f', 'concat', '-safe', '0', '-i', listFile, '-c', 'copy', output],
     { stdio: ['ignore', 'pipe', 'pipe'] },
   );
   fs.unlinkSync(listFile);
 }
 
 function makeSilence(duration, outPath) {
-  execSync(
-    `ffmpeg -y -f lavfi -i anullsrc=r=24000:cl=mono -t ${duration} -q:a 9 -acodec libmp3lame "${outPath}"`,
+  execFileSync(
+    'ffmpeg',
+    ['-y', '-f', 'lavfi', '-i', 'anullsrc=r=24000:cl=mono', '-t', String(duration),
+     '-q:a', '9', '-acodec', 'libmp3lame', outPath],
     { stdio: ['ignore', 'pipe', 'pipe'] },
   );
 }
@@ -184,6 +190,15 @@ async function main() {
   const args = parseArgs(process.argv);
   if (args.help || !args.script || !args.outDir) usage();
 
+  if (!args.yes && process.env.HUASHU_CLOUD_OK !== '1') {
+    console.error(
+      '[云能力确认] 本管线会把解说稿文本分段发送到豆包TTS官方接口(openspeech.bytedance.com,' +
+      '使用你自己的key合成配音)。\n确认无误请重跑并加 --yes,或设置环境变量 HUASHU_CLOUD_OK=1。' +
+      '数据流向声明见 SECURITY.md。',
+    );
+    process.exit(2);
+  }
+
   const scriptPath = path.resolve(args.script);
   const outDir = path.resolve(args.outDir);
   const audioDir = path.join(outDir, 'audio');