diff --git a/md/wechat-auto-reply-requirements.md b/md/wechat-auto-reply-requirements.md
index 908a261..45c70e4 100644
--- a/md/wechat-auto-reply-requirements.md
+++ b/md/wechat-auto-reply-requirements.md
@@ -77,7 +77,17 @@
- ⚠️ 观察:SOUL.md 那句「约时间交本人确认」是**软约束**,约饭案例里模型仍主动接话——真正的自动/审核安全门必须落在**环节4 策略层**,不能只靠人设。
- 待办:一次结构化调用同时出 `{should_reply, confidence, draft, risk_flags}` 留给环节4 一起做。
-### 环节 4 · 自动 vs 审核 —— **白名单直发,其余进 viewer 待回复**
+### 环节 4 · 自动 vs 审核 —— ✅ **确定性策略引擎 + viewer 审批/白名单 UI(2026-07-10)**
+- **策略引擎**(代码,非 LLM 判)`wechat_plain/scripts/auto-reply/wechat_reply_policy.py`:`decide(event,draft,confidence)` → `skip|auto|review`。default-deny + 硬安全 override:群未@我/公众号/系统消息/自己发的/模型说别回 → skip;命中敏感词(钱/承诺/约时间/隐私,白名单也拉回)或低置信 → review;仅白名单+总开关开 → auto;其余 → review。demo 8/8 通过。
+- **配置 + 审批 UI 落在手机端 `~/wechat-viewer`(Flask)**,非独立文件(详见 `wechat-viewer-changes.md`):
+ - 新表 `reply_whitelist` + `reply_queue`(`local_db.py`)。
+ - `/whitelist` 白名单管理页;`/approvals` 审批页(待审列表 + **可编辑草稿** + 【发送】【取消】)。
+ - 发送复用 `send_text_via_hook`(LSPosed 内部 API)。
+ - 真机验证:白名单增删查、审批发送到 filehelper(status=sent)、取消(cancelled) 全通。
+- ⚠️ 印证环节3 的发现:安全门是**确定性代码**(敏感词硬拦),不靠人设软约束。
+- 待接:编排器把环节1 队列串到策略+队列(见环节5)。
+
+### (原)环节 4 · 自动 vs 审核 —— **白名单直发,其余进 viewer 待回复**
- **白名单**(联系人 / 群 / 群里某发言人)+ 生成成功 → **直接发送**。
- **非白名单** → 写入 **Pixel 本地 wechat viewer 的"待回复列表"**,人工点击"发送"才真正发出。
- 落地新增:①「待回复队列」存储(sqlite/json);② wechat viewer 的"发送"按钮接到发送链路。
diff --git a/wechat_plain/scripts/auto-reply/viewer_templates/approvals.html b/wechat_plain/scripts/auto-reply/viewer_templates/approvals.html
new file mode 100644
index 0000000..abae8b8
--- /dev/null
+++ b/wechat_plain/scripts/auto-reply/viewer_templates/approvals.html
@@ -0,0 +1,108 @@
+{% extends "base.html" %}
+{% block title %}自动回复审批 - WeChat Viewer{% endblock %}
+{% block body %}
+
+
+
+
+
待审批
+
全部
+
已发送
+
自动已发
+
已取消
+
+
+
+
+{% endblock %}
diff --git a/wechat_plain/scripts/auto-reply/viewer_templates/whitelist.html b/wechat_plain/scripts/auto-reply/viewer_templates/whitelist.html
new file mode 100644
index 0000000..fcc6cd1
--- /dev/null
+++ b/wechat_plain/scripts/auto-reply/viewer_templates/whitelist.html
@@ -0,0 +1,69 @@
+{% extends "base.html" %}
+{% block title %}回复白名单 - WeChat Viewer{% endblock %}
+{% block body %}
+
+
+
+
在白名单里的联系人 / 群,其消息可被自动回复(仍受敏感词等安全规则拦截,命中会转人工审批)。不在名单里的一律进「自动回复审批」等你手动发。
群 id 以 @chatroom 结尾会自动识别为群。
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/wechat_plain/scripts/auto-reply/wechat-viewer-changes.md b/wechat_plain/scripts/auto-reply/wechat-viewer-changes.md
new file mode 100644
index 0000000..ea5c37c
--- /dev/null
+++ b/wechat_plain/scripts/auto-reply/wechat-viewer-changes.md
@@ -0,0 +1,52 @@
+# wechat-viewer 集成改动(环节4 策略 + 审批/白名单 UI)
+
+> 落地在手机端 Flask 应用 `~/wechat-viewer`(该 app 有自己的 git 工作树,本文件只记录本次为
+> 微信自动回复新增的部分;两个新模板全文见同目录 `viewer_templates/`)。
+
+## 新增数据表(`local_db.py` init_db)
+
+```sql
+CREATE TABLE reply_whitelist ( -- 自动回复白名单
+ id INTEGER PK, kind TEXT, -- 'contact'|'group'
+ wxid TEXT UNIQUE, name TEXT, added_at TEXT);
+
+CREATE TABLE reply_queue ( -- 策略判定后落地
+ id INTEGER PK, msg_id INTEGER, talker TEXT, sender TEXT, sender_name TEXT,
+ kind TEXT, incoming TEXT, draft TEXT,
+ action TEXT, -- 'auto'|'review'
+ reason TEXT,
+ status TEXT, -- pending|sent|cancelled|auto_sent|failed
+ created_at TEXT, sent_at TEXT);
+```
+
+`local_db.py` 新增辅助函数:`whitelist_list/add/remove/wxids`、`reply_add/list/get/update`。
+
+## 新增路由(`app.py`)
+
+| 路由 | 作用 |
+|---|---|
+| `GET /whitelist` | 白名单管理页 |
+| `GET /api/whitelist` / `POST /api/whitelist` / `POST /api/whitelist//delete` | 白名单增删查(`xxx@chatroom` 自动识别为群) |
+| `GET /approvals` | 自动回复审批页 |
+| `GET /api/replies?status=` | 队列列表(pending / sent / auto_sent / cancelled / 全部) |
+| `POST /api/reply//send` | 用(可编辑的)草稿发到会话 → `send_text_via_hook(talker,text)` → 标 sent/failed |
+| `POST /api/reply//cancel` | 标 cancelled |
+
+发送复用 `~/.hermes/event_bus/wechat_hook_send.py` 的 `send_text_via_hook`(LSPosed 内部 API 广播 `SEND_MSG_INTERNAL`)。
+
+## 新增页面(`templates/`,见 viewer_templates/)
+- `approvals.html`:待审批列表,每条含对方原文 + **可编辑草稿框** + 【✅发送】【🗑取消】;另有已发送/自动已发/已取消筛选 tab。
+- `whitelist.html`:白名单增删列表。
+
+`base.html` 抽屉导航加两个入口:✅自动回复审批 / ⭐回复白名单。
+
+## 真机验证(2026-07-10)
+- 页面 `/whitelist` `/approvals` 均 200。
+- 白名单:加「测试好友」→ 列表可见 → 删除 OK。
+- 审批:插入 pending → `/api/replies` 返回 → 点发送 → `send_text_via_hook` 广播到 filehelper(result=0, status=sent)→ 草稿按编辑后内容更新、状态转 sent、sent_at 记录。
+- 取消:pending → cancel → status=cancelled。
+- 测试数据已清空。
+
+## 待接(环节5 / 编排)
+- **编排器**:把环节1 队列 `incoming.jsonl` → 上下文 → Hermes 生成 → 策略 `decide()`(白名单读 `reply_whitelist`)→ auto 直接 `send_text_via_hook` 并 `reply_add(status=auto_sent)` / review 写 `reply_add(status=pending)`。
+- 群 @回复的 `@昵称` 前缀、原生引用气泡(A11y)。
diff --git a/wechat_plain/scripts/auto-reply/wechat_reply_policy.py b/wechat_plain/scripts/auto-reply/wechat_reply_policy.py
new file mode 100644
index 0000000..b98aef0
--- /dev/null
+++ b/wechat_plain/scripts/auto-reply/wechat_reply_policy.py
@@ -0,0 +1,177 @@
+#!/usr/bin/env python3
+"""环节4 · 策略判定 —— 确定性安全闸(自动发 / 进审核 / 跳过)
+
+为什么是代码不是让 LLM 判:环节3 已验证「SOUL.md 里写的软约束会漏」(约饭案例模型仍
+主动接话)。真正的自动/审核安全门必须是确定性、可审计、LLM 说服不动的硬规则。
+
+输入:watcher 事件 + Hermes 生成的草稿 + (可选)模型置信/should_reply 信号
+输出:{action: auto|review|skip, reason}
+ skip —— 压根不该回(群里没@我、公众号、系统消息、自己发的、模型说别回)
+ auto —— 白名单 + 文本 + 无敏感词 + 总开关开 → 直接发(环节5)
+ review —— 其余一律进审核队列 pending.jsonl,人工在 wechat viewer 里点发送
+
+设计=default-deny:不在白名单、命中敏感词、低置信、拿不准 → 全部 review。
+先保守起步(auto_send_enabled 默认 False = 全审核),跑顺了再对白名单放开自动。
+
+配置 ~/.hermes/wechat_reply/policy_config.json(首次自动生成默认值)。
+"""
+import json
+import os
+import time
+
+WORK_DIR = os.environ.get(
+ "WECHAT_REPLY_DIR",
+ "/data/data/com.termux/files/home/.hermes/wechat_reply")
+CONFIG_FILE = f"{WORK_DIR}/policy_config.json"
+PENDING_FILE = f"{WORK_DIR}/pending.jsonl" # 审核队列(环节5 viewer 读)
+DECISION_LOG = f"{WORK_DIR}/decisions.jsonl"
+
+DEFAULT_CONFIG = {
+ "auto_send_enabled": False, # 总开关:False = 一切都进审核(保守起步)
+ "whitelist_contacts": [], # 允许自动发的 wxid(私聊)
+ "whitelist_groups": [], # 允许 @我 时自动发的 chatroom id
+ "min_confidence": 0.75, # 低于此→审核(模型给了置信才生效)
+ # 命中即强制 review(即使白名单也拉回人工)——钱/承诺/约时间/隐私
+ "block_keywords": [
+ "钱", "转账", "红包", "借", "还款", "付款", "打款", "汇款", "定金", "报价",
+ "价格", "多少钱", "合同", "签", "承诺", "保证", "担保",
+ "几点", "明天", "后天", "周末", "约", "见面", "面基", "碰头", "地址在",
+ "身份证", "银行卡", "验证码", "密码", "手机号", "住址",
+ ],
+}
+
+# 非文本消息类型(草稿通常无意义)
+NON_TEXT_TYPES = {3, 34, 43, 47, 42, 48, 49, 62, 10000}
+SYSTEM_MARKERS = ("撤回了一条消息", "拍了拍", "拍一拍", "添加到通讯录", "以上是打招呼的内容")
+
+
+def load_config():
+ try:
+ with open(CONFIG_FILE) as f:
+ cfg = json.load(f)
+ return {**DEFAULT_CONFIG, **cfg}
+ except (OSError, ValueError):
+ os.makedirs(WORK_DIR, exist_ok=True)
+ with open(CONFIG_FILE, "w") as f:
+ json.dump(DEFAULT_CONFIG, f, ensure_ascii=False, indent=2)
+ return dict(DEFAULT_CONFIG)
+
+
+def _hit_keyword(text, keywords):
+ for k in keywords:
+ if k and k in text:
+ return k
+ return None
+
+
+def decide(event, draft, confidence=None, should_reply=True, self_wxid="", cfg=None):
+ """核心判定。返回 {action, reason, hit?}。纯函数、无副作用(写队列在 route())。"""
+ cfg = cfg or load_config()
+ kind = event.get("kind", "")
+ sender = event.get("sender") or event.get("talker") or ""
+ talker = event.get("talker", "")
+ body = event.get("body", "") or ""
+ mtype = event.get("type", 0)
+
+ # ── skip 区:压根不回 ──
+ if sender and self_wxid and sender == self_wxid:
+ return {"action": "skip", "reason": "self:自己发的"}
+ if kind == "group_other":
+ return {"action": "skip", "reason": "group:群里没@我也没引用我"}
+ if str(talker).startswith("gh_") or str(sender).startswith("gh_"):
+ return {"action": "skip", "reason": "official:公众号/服务号推送"}
+ if any(m in body for m in SYSTEM_MARKERS):
+ return {"action": "skip", "reason": "system:系统消息(撤回/拍一拍/加好友)"}
+ if not should_reply:
+ return {"action": "skip", "reason": "model:模型判定无需回复"}
+ if not (draft or "").strip():
+ if mtype in NON_TEXT_TYPES:
+ return {"action": "skip", "reason": f"nontext:非文本(type={mtype})且无草稿"}
+ return {"action": "skip", "reason": "empty:草稿为空"}
+
+ # ── 硬安全override:命中敏感词一律 review(白名单也拉回)──
+ hit = _hit_keyword(body + "\n" + (draft or ""), cfg["block_keywords"])
+ if hit:
+ return {"action": "review", "reason": f"sensitive:命中敏感词「{hit}」", "hit": hit}
+
+ # ── 低置信 → review ──
+ if confidence is not None and confidence < cfg["min_confidence"]:
+ return {"action": "review", "reason": f"lowconf:置信{confidence}<{cfg['min_confidence']}"}
+
+ # ── 白名单 + 总开关 → auto ──
+ is_group = event.get("isGroup") or str(talker).endswith("@chatroom")
+ whitelisted = (
+ (not is_group and sender in cfg["whitelist_contacts"]) or
+ (is_group and talker in cfg["whitelist_groups"])
+ )
+ if whitelisted and cfg["auto_send_enabled"]:
+ return {"action": "auto", "reason": "whitelist:白名单+总开关开→直接发"}
+
+ # ── default-deny:其余全部人工审核 ──
+ if not cfg["auto_send_enabled"]:
+ return {"action": "review", "reason": "conservative:总开关关(全审核)"}
+ return {"action": "review", "reason": "notwhitelist:不在白名单→人工"}
+
+
+def route(event, draft, decision, meta=None):
+ """按判定落地:review→写审核队列 pending.jsonl;一切→记 decisions.jsonl 审计。"""
+ os.makedirs(WORK_DIR, exist_ok=True)
+ rec = {
+ "ts": int(time.time()),
+ "msgId": event.get("msgId"),
+ "talker": event.get("talker"),
+ "sender": event.get("sender"),
+ "kind": event.get("kind"),
+ "action": decision["action"],
+ "reason": decision["reason"],
+ "draft": draft,
+ **(meta or {}),
+ }
+ with open(DECISION_LOG, "a") as f:
+ f.write(json.dumps(rec, ensure_ascii=False) + "\n")
+ if decision["action"] == "review":
+ item = {**rec, "status": "pending"}
+ with open(PENDING_FILE, "a") as f:
+ f.write(json.dumps(item, ensure_ascii=False) + "\n")
+ return rec
+
+
+# ── 自检 demo(lazy 规则:留一个可跑的检查)──
+def _demo():
+ cfg = dict(DEFAULT_CONFIG)
+ cfg["whitelist_contacts"] = ["wxid_friend"]
+ cfg["auto_send_enabled"] = True
+ cases = [
+ ("陌生猎头(非白名单)", {"kind": "private", "talker": "wxid_x", "sender": "wxid_x",
+ "body": "有急招岗位吗", "type": 1}, "暂不考虑,谢谢", None, "review"),
+ ("白名单好友闲聊", {"kind": "private", "talker": "wxid_friend", "sender": "wxid_friend",
+ "body": "在干嘛", "type": 1}, "摸鱼呢哈哈", None, "auto"),
+ ("白名单好友约饭(敏感词)", {"kind": "private", "talker": "wxid_friend", "sender": "wxid_friend",
+ "body": "明天中午一起吃饭?", "type": 1}, "可以啊几点", None, "review"),
+ ("白名单好友谈钱", {"kind": "private", "talker": "wxid_friend", "sender": "wxid_friend",
+ "body": "借我5000周转下", "type": 1}, "行", None, "review"),
+ ("群里没@我", {"kind": "group_other", "talker": "x@chatroom", "sender": "wxid_a",
+ "isGroup": True, "body": "哈哈", "type": 1}, "草稿", None, "skip"),
+ ("公众号推送", {"kind": "private", "talker": "gh_abc", "sender": "gh_abc",
+ "body": "文章", "type": 49}, "", None, "skip"),
+ ("撤回系统消息", {"kind": "group_at_me", "talker": "x@chatroom", "sender": "wxid_a",
+ "body": "\"张三\" 撤回了一条消息", "type": 10000}, "", None, "skip"),
+ ("低置信", {"kind": "private", "talker": "wxid_friend", "sender": "wxid_friend",
+ "body": "你觉得呢", "type": 1}, "嗯", 0.4, "review"),
+ ]
+ ok = True
+ for name, ev, draft, conf, expect in cases:
+ d = decide(ev, draft, confidence=conf, cfg=cfg)
+ flag = "OK " if d["action"] == expect else "FAIL"
+ if d["action"] != expect:
+ ok = False
+ print(f"[{flag}] {name}: 期望={expect} 实得={d['action']} ({d['reason']})")
+ print("=== ALL PASS ===" if ok else "=== 有 FAIL ===")
+ return ok
+
+
+if __name__ == "__main__":
+ import sys
+ if "--demo" in sys.argv:
+ sys.exit(0 if _demo() else 1)
+ print("用法: wechat_reply_policy.py --demo (或作为库 import decide/route)")