diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
index d0601412c..bb949ee67 100644
--- a/.claude-plugin/marketplace.json
+++ b/.claude-plugin/marketplace.json
@@ -145,6 +145,12 @@
},
"source": "./plugins/security-guidance",
"category": "security"
+ },
+ {
+ "name": "vibeguard",
+ "description": "Community plugin: protect secrets/PII by blocking prompts and showing VibeGuard-style placeholders (no MITM)",
+ "source": "./plugins/vibeguard",
+ "category": "security"
}
]
}
diff --git a/plugins/README.md b/plugins/README.md
index cf4a21ecc..9b4a25a03 100644
--- a/plugins/README.md
+++ b/plugins/README.md
@@ -25,6 +25,7 @@ Learn more in the [official plugins documentation](https://docs.claude.com/en/do
| [pr-review-toolkit](./pr-review-toolkit/) | Comprehensive PR review agents specializing in comments, tests, error handling, type design, code quality, and code simplification | **Command:** `/pr-review-toolkit:review-pr` - Run with optional review aspects (comments, tests, errors, types, code, simplify, all)
**Agents:** `comment-analyzer`, `pr-test-analyzer`, `silent-failure-hunter`, `type-design-analyzer`, `code-reviewer`, `code-simplifier` |
| [ralph-wiggum](./ralph-wiggum/) | Interactive self-referential AI loops for iterative development. Claude works on the same task repeatedly until completion | **Commands:** `/ralph-loop`, `/cancel-ralph` - Start/stop autonomous iteration loops
**Hook:** Stop - Intercepts exit attempts to continue iteration |
| [security-guidance](./security-guidance/) | Security reminder hook that warns about potential security issues when editing files | **Hook:** PreToolUse - Monitors 9 security patterns including command injection, XSS, eval usage, dangerous HTML, pickle deserialization, and os.system calls |
+| [vibeguard](./vibeguard/) | Community plugin: protect secrets/PII by blocking prompts and showing VibeGuard-style placeholders (no MITM) | **Hook:** UserPromptSubmit - Blocks prompts containing secrets/PII and prints a redacted version to copy
**Command:** `/vibeguard` - Quick usage |
## Installation
diff --git a/plugins/vibeguard/.claude-plugin/plugin.json b/plugins/vibeguard/.claude-plugin/plugin.json
new file mode 100644
index 000000000..bbc758650
--- /dev/null
+++ b/plugins/vibeguard/.claude-plugin/plugin.json
@@ -0,0 +1,8 @@
+{
+ "name": "vibeguard",
+ "version": "0.1.0",
+ "description": "Protect secrets/PII in Claude Code by blocking prompts and showing VibeGuard-style placeholders (no MITM)",
+ "author": {
+ "name": "inkdust2021"
+ }
+}
diff --git a/plugins/vibeguard/.gitignore b/plugins/vibeguard/.gitignore
new file mode 100644
index 000000000..7a60b85e1
--- /dev/null
+++ b/plugins/vibeguard/.gitignore
@@ -0,0 +1,2 @@
+__pycache__/
+*.pyc
diff --git a/plugins/vibeguard/README.md b/plugins/vibeguard/README.md
new file mode 100644
index 000000000..5058cdd28
--- /dev/null
+++ b/plugins/vibeguard/README.md
@@ -0,0 +1,83 @@
+# vibeguard (Claude Code plugin)
+
+Community plugin (not affiliated with Anthropic / Claude Code).
+
+Protects secrets/PII by **blocking prompts before they are sent**, and printing a **VibeGuard-style placeholder** version for you to copy.
+
+## What this plugin does
+
+The main risk when pasting secrets (API keys, tokens, emails, phone numbers, etc.) into Claude Code is that they may be sent to model providers.
+
+This plugin runs on **UserPromptSubmit**:
+
+- Detects configured secrets/PII in `user_prompt` (keywords / regex / builtin patterns)
+- If matched, **blocks** the prompt and prints a redacted version using placeholders like `__VG____`
+
+## Requirements
+
+- No MITM proxy required
+- No VibeGuard binary required
+- Needs a `vibeguard.config.json` redaction rules file (example below)
+
+## Enable (per project)
+
+Create:
+
+`./.claude/vibeguard.local.md`
+
+Example:
+
+```md
+---
+enabled: true
+guard_prompt: true
+guard_action: block
+guard_fail_closed: true
+# redact_config: "./vibeguard.config.json"
+---
+```
+
+Fields:
+
+- `enabled`: enable/disable (default: false)
+- `guard_prompt`: enable prompt guard mode (default: false)
+- `guard_action`: `block` (default) or `warn` (warn will NOT prevent sending)
+- `guard_fail_closed`: if true, block sending when no config file is found (default: true)
+- `redact_config` / `redaction_config` / `config_json`: optional path to `vibeguard.config.json` (defaults to project root / `.claude/` / `~/.claude/`)
+
+Create `vibeguard.config.json` in your project root:
+
+```json
+{
+ "enabled": true,
+ "placeholder_prefix": "__VG_",
+ "patterns": {
+ "keywords": [
+ { "value": "example-secret-123", "category": "API_KEY" }
+ ],
+ "regex": [
+ { "pattern": "sk-[A-Za-z0-9]{48}", "category": "OPENAI_KEY" }
+ ],
+ "builtin": ["email", "china_phone", "uuid", "ipv4"],
+ "exclude": ["example.com", "localhost", "127.0.0.1", "0.0.0.0"]
+ }
+}
+```
+
+## Notes
+
+- This is a **community integration plugin** and is **not affiliated** with Anthropic / Claude Code.
+- It cannot automatically rewrite what gets sent; it blocks and prints a redacted version for you to copy.
+
+---
+
+## 中文说明
+
+这是一个社区插件(非官方),用于在 **不走 MITM** 的前提下,尽可能降低“把密钥/PII 明文发送给模型提供商”的风险。
+
+工作方式:
+
+- 在 **UserPromptSubmit** 时扫描用户输入(关键词 / 正则 / 内置 PII 规则)
+- 命中后默认 **阻止发送**,并输出一份使用 VibeGuard 风格占位符的文本(如 `__VG_<类别>___`)供你复制重发
+
+注意:插件层面无法“自动改写”即将发送的 prompt,因此采用“阻断 + 提示复制替换版”的交互方式。
diff --git a/plugins/vibeguard/commands/vibeguard.md b/plugins/vibeguard/commands/vibeguard.md
new file mode 100644
index 000000000..bfda89821
--- /dev/null
+++ b/plugins/vibeguard/commands/vibeguard.md
@@ -0,0 +1,44 @@
+---
+description: VibeGuard integration quickstart (community plugin)
+allowed-tools: []
+---
+
+## What this does
+
+This plugin blocks prompts containing configured secrets/PII before they are sent to model providers, and prints a VibeGuard-style placeholder version for you to copy and re-send.
+
+## Enable (per project)
+
+Create `./.claude/vibeguard.local.md` in your project root:
+
+```md
+---
+enabled: true
+guard_prompt: true
+guard_action: block
+guard_fail_closed: true
+---
+```
+
+Then restart your Claude Code session.
+
+Create `vibeguard.config.json` in your project root (same schema as opencode-vibeguard):
+
+```json
+{
+ "enabled": true,
+ "placeholder_prefix": "__VG_",
+ "patterns": {
+ "keywords": [{ "value": "example-secret-123", "category": "API_KEY" }],
+ "regex": [{ "pattern": "sk-[A-Za-z0-9]{48}", "category": "OPENAI_KEY" }],
+ "builtin": ["email", "china_phone", "uuid", "ipv4"],
+ "exclude": ["example.com", "localhost", "127.0.0.1", "0.0.0.0"]
+ }
+}
+```
+
+---
+
+## 中文
+
+不走 MITM:在发送前检测到密钥/PII 就阻止发送,并打印“占位符替换版”供你复制重发(注意:无法自动改写即将发送的 prompt,只能阻断 + 提示)。
diff --git a/plugins/vibeguard/hooks-handlers/user-prompt-submit.py b/plugins/vibeguard/hooks-handlers/user-prompt-submit.py
new file mode 100644
index 000000000..4ac6cebe1
--- /dev/null
+++ b/plugins/vibeguard/hooks-handlers/user-prompt-submit.py
@@ -0,0 +1,604 @@
+#!/usr/bin/env python3
+"""
+Claude Code 插件:VibeGuard(UserPromptSubmit)
+
+目标:在**不使用 MITM 代理**的前提下,尽可能降低“用户把敏感信息直接发给模型提供商”的风险。
+
+实现方式:
+- 在 UserPromptSubmit 事件中读取用户输入(user_prompt)
+- 使用本地规则检测关键词/正则/内置 PII 模式
+- 若命中则默认阻断发送,并输出一份“占位符替换版文本”供用户复制粘贴
+
+说明:
+- 该模式无法在客户端插件层面“自动改写即将发送的 prompt”,因此采用“阻断 + 提示复制替换版”的交互
+- 占位符格式保持与 VibeGuard 一致:__VG____(hash12 为 HMAC-SHA256 截断)
+"""
+
+from __future__ import annotations
+
+import hashlib
+import hmac
+import json
+import os
+import re
+import sys
+from dataclasses import dataclass
+from typing import Any, Iterable
+
+
+_CONFIG_REL_PATH = os.path.join(".claude", "vibeguard.local.md")
+
+
+@dataclass(frozen=True)
+class _GuardConfig:
+ enabled: bool
+ action: str # "block" | "warn"
+ fail_closed: bool
+ redact_config_path: str | None
+
+
+@dataclass(frozen=True)
+class _RedactConfig:
+ enabled: bool
+ debug: bool
+ prefix: str
+ patterns: "_PatternSet"
+ loaded_from: str
+
+
+@dataclass(frozen=True)
+class _KeywordRule:
+ value: str
+ category: str
+
+
+@dataclass(frozen=True)
+class _RegexRule:
+ pattern: str
+ flags: str
+ category: str
+
+
+@dataclass(frozen=True)
+class _PatternSet:
+ keywords: list[_KeywordRule]
+ regex: list[_RegexRule]
+ exclude: set[str]
+
+
+def _read_text(path: str) -> str:
+ with open(path, "r", encoding="utf-8") as f:
+ return f.read()
+
+
+def _parse_frontmatter(md: str) -> dict[str, Any]:
+ # 轻量 frontmatter 解析:只支持 key: value 形式(与仓库内其它插件保持一致)
+ lines = md.splitlines()
+ if not lines or lines[0].strip() != "---":
+ return {}
+
+ end_index = None
+ for i in range(1, len(lines)):
+ if lines[i].strip() == "---":
+ end_index = i
+ break
+
+ if end_index is None:
+ return {}
+
+ cfg: dict[str, Any] = {}
+ for raw in lines[1:end_index]:
+ line = raw.strip()
+ if not line or line.startswith("#"):
+ continue
+ if ":" not in line:
+ continue
+ key, value = line.split(":", 1)
+ key = key.strip()
+ value = value.strip()
+ lower = value.lower()
+ if lower == "true":
+ cfg[key] = True
+ continue
+ if lower == "false":
+ cfg[key] = False
+ continue
+ if len(value) >= 2 and (
+ (value[0] == '"' and value[-1] == '"') or (value[0] == "'" and value[-1] == "'")
+ ):
+ value = value[1:-1]
+ cfg[key] = value
+ return cfg
+
+
+def _load_guard_config(project_dir: str) -> _GuardConfig | None:
+ cfg_path = os.path.join(project_dir, _CONFIG_REL_PATH)
+ if not os.path.isfile(cfg_path):
+ return None
+
+ fm = _parse_frontmatter(_read_text(cfg_path))
+ enabled = bool(fm.get("enabled", False))
+ if not enabled:
+ return _GuardConfig(enabled=False, action="block", fail_closed=True, redact_config_path=None)
+
+ guard_prompt = bool(fm.get("guard_prompt", False))
+ if not guard_prompt:
+ return _GuardConfig(enabled=False, action="block", fail_closed=True, redact_config_path=None)
+
+ action_raw = str(fm.get("guard_action", "block")).strip().lower()
+ action = "warn" if action_raw == "warn" else "block"
+ fail_closed = bool(fm.get("guard_fail_closed", True))
+
+ path_raw = fm.get("redact_config") or fm.get("redaction_config") or fm.get("config_json")
+ redact_config_path = None
+ if isinstance(path_raw, str) and path_raw.strip():
+ redact_config_path = os.path.expanduser(path_raw.strip())
+ if not os.path.isabs(redact_config_path):
+ redact_config_path = os.path.join(project_dir, redact_config_path)
+
+ return _GuardConfig(
+ enabled=True,
+ action=action,
+ fail_closed=fail_closed,
+ redact_config_path=redact_config_path,
+ )
+
+
+def _read_json(path: str) -> dict[str, Any] | None:
+ try:
+ with open(path, "r", encoding="utf-8") as f:
+ return json.load(f)
+ except Exception:
+ return None
+
+
+def _sanitize_category(input_value: Any) -> str:
+ raw = str(input_value or "").strip()
+ if not raw:
+ return "TEXT"
+ upper = raw.upper()
+ safe = re.sub(r"[^A-Z0-9_]", "_", upper)
+ safe = re.sub(r"_+", "_", safe).strip("_")
+ return safe or "TEXT"
+
+
+def _peel_inline_flags(pattern: str, flags: str) -> tuple[str, str]:
+ # 与 opencode-vibeguard 的实现保持一致:只处理开头连续的 (?i)/( ?m )
+ p = str(pattern or "")
+ f = str(flags or "")
+ while True:
+ if p.startswith("(?i)"):
+ p = p[4:]
+ if "i" not in f:
+ f += "i"
+ continue
+ if p.startswith("(?m)"):
+ p = p[4:]
+ if "m" not in f:
+ f += "m"
+ continue
+ break
+ return p, f
+
+
+_BUILTIN: dict[str, tuple[str, str, str]] = {
+ # name: (pattern, flags, category)
+ "email": (r"[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}", "i", "EMAIL"),
+ "china_phone": (r"(? _PatternSet:
+ raw = patterns if isinstance(patterns, dict) else {}
+
+ keywords_raw = raw.get("keywords") if isinstance(raw.get("keywords"), list) else []
+ regex_raw = raw.get("regex") if isinstance(raw.get("regex"), list) else []
+ builtin_raw = raw.get("builtin") if isinstance(raw.get("builtin"), list) else []
+ exclude_raw = raw.get("exclude") if isinstance(raw.get("exclude"), list) else []
+
+ keywords: list[_KeywordRule] = []
+ for item in keywords_raw:
+ if not isinstance(item, dict):
+ continue
+ value = str(item.get("value") or "").strip()
+ if not value:
+ continue
+ category = _sanitize_category(item.get("category"))
+ keywords.append(_KeywordRule(value=value, category=category))
+
+ regex: list[_RegexRule] = []
+ for item in regex_raw:
+ if not isinstance(item, dict):
+ continue
+ pattern = str(item.get("pattern") or "").strip()
+ if not pattern:
+ continue
+ category = _sanitize_category(item.get("category"))
+ flags = str(item.get("flags") or "")
+ peeled_pattern, peeled_flags = _peel_inline_flags(pattern, flags)
+ regex.append(_RegexRule(pattern=peeled_pattern, flags=peeled_flags, category=category))
+
+ for name in builtin_raw:
+ key = str(name or "").strip()
+ if not key:
+ continue
+ rule = _BUILTIN.get(key)
+ if not rule:
+ continue
+ pattern, flags, category = rule
+ regex.append(_RegexRule(pattern=pattern, flags=flags, category=category))
+
+ exclude = {str(x or "") for x in exclude_raw}
+
+ return _PatternSet(keywords=keywords, regex=regex, exclude=exclude)
+
+
+def _default_redact_config_candidates(project_dir: str) -> list[str]:
+ home = os.path.expanduser("~")
+ return [
+ os.path.join(project_dir, "vibeguard.config.json"),
+ os.path.join(project_dir, ".claude", "vibeguard.config.json"),
+ os.path.join(home, ".claude", "vibeguard.config.json"),
+ ]
+
+
+def _load_redact_config(project_dir: str, guard_cfg: _GuardConfig) -> _RedactConfig | None:
+ candidates: list[str] = []
+
+ env = os.environ.get("CLAUDE_VIBEGUARD_CONFIG")
+ if env and str(env).strip():
+ p = os.path.expanduser(str(env).strip())
+ if not os.path.isabs(p):
+ p = os.path.join(project_dir, p)
+ candidates.append(p)
+
+ if guard_cfg.redact_config_path:
+ candidates.append(guard_cfg.redact_config_path)
+
+ candidates.extend(_default_redact_config_candidates(project_dir))
+
+ seen = set()
+ for path in candidates:
+ if not path:
+ continue
+ path = os.path.abspath(path)
+ if path in seen:
+ continue
+ seen.add(path)
+ if not os.path.isfile(path):
+ continue
+ raw = _read_json(path)
+ if not isinstance(raw, dict):
+ continue
+ enabled = bool(raw.get("enabled", False))
+ debug = bool(raw.get("debug", False))
+ prefix = str(raw.get("placeholder_prefix") or "__VG_").strip() or "__VG_"
+ patterns = _build_pattern_set(raw.get("patterns") if isinstance(raw.get("patterns"), dict) else {})
+ return _RedactConfig(enabled=enabled, debug=debug, prefix=prefix, patterns=patterns, loaded_from=path)
+
+ return None
+
+
+@dataclass
+class _Match:
+ start: int
+ end: int
+ original: str
+ category: str
+ placeholder: str = ""
+
+
+def _subtract_covered(start: int, end: int, covered: list[tuple[int, int]]) -> list[tuple[int, int]]:
+ if start >= end:
+ return []
+ out: list[tuple[int, int]] = []
+ cur = start
+ for c_start, c_end in covered:
+ if c_end <= cur:
+ continue
+ if c_start >= end:
+ break
+ if c_start > cur:
+ out.append((cur, min(c_start, end)))
+ if c_end >= end:
+ cur = end
+ break
+ cur = max(cur, c_end)
+ if cur < end:
+ out.append((cur, end))
+ return out
+
+
+def _insert_covered(covered: list[tuple[int, int]], span: tuple[int, int]) -> list[tuple[int, int]]:
+ s, e = span
+ if s >= e:
+ return covered
+
+ i = 0
+ while i < len(covered) and covered[i][0] <= s:
+ i += 1
+ covered.insert(i, (s, e))
+
+ if len(covered) <= 1:
+ return covered
+
+ merged: list[tuple[int, int]] = []
+ for cs, ce in covered:
+ if not merged:
+ merged.append((cs, ce))
+ continue
+ ls, le = merged[-1]
+ if cs <= le:
+ merged[-1] = (ls, max(le, ce))
+ continue
+ merged.append((cs, ce))
+ return merged
+
+
+class _PlaceholderSession:
+ def __init__(self, prefix: str, key: bytes):
+ self.prefix = prefix
+ self.key = key
+ self.forward: dict[str, str] = {}
+ self.reverse: dict[str, str] = {}
+
+ def _generate_base(self, original: str, category: str) -> str:
+ cat = _sanitize_category(category)
+ digest = hmac.new(self.key, original.encode("utf-8"), hashlib.sha256).digest()
+ hash12 = digest.hex()[:12] # 小写十六进制
+ return f"{self.prefix}{cat}_{hash12}"
+
+ def get_or_create_placeholder(self, original: str, category: str) -> str:
+ existing = self.reverse.get(original)
+ if existing:
+ return existing
+
+ base = self._generate_base(original, category) + "__"
+ current = self.forward.get(base)
+ if current is None:
+ self.forward[base] = original
+ self.reverse[original] = base
+ return base
+
+ if current == original:
+ self.reverse[original] = base
+ return base
+
+ # 极低概率:hash12 冲突,按 VibeGuard 风格追加 _N 后缀
+ without_suffix = base[:-2]
+ i = 2
+ while True:
+ candidate = f"{without_suffix}_{i}__"
+ prev = self.forward.get(candidate)
+ if prev is None:
+ self.forward[candidate] = original
+ self.reverse[original] = candidate
+ return candidate
+ if prev == original:
+ self.reverse[original] = candidate
+ return candidate
+ i += 1
+
+
+def _iter_keyword_matches(text: str, rules: Iterable[_KeywordRule], exclude: set[str]) -> list[_Match]:
+ found: list[_Match] = []
+ for rule in rules:
+ needle = rule.value
+ if not needle:
+ continue
+ idx = 0
+ while True:
+ pos = text.find(needle, idx)
+ if pos < 0:
+ break
+ start = pos
+ end = pos + len(needle)
+ idx = end
+ original = text[start:end]
+ if original in exclude:
+ continue
+ found.append(_Match(start=start, end=end, original=original, category=rule.category))
+ return found
+
+
+def _regex_flags_to_re(flags: str) -> int:
+ out = 0
+ f = str(flags or "")
+ if "i" in f:
+ out |= re.IGNORECASE
+ if "m" in f:
+ out |= re.MULTILINE
+ return out
+
+
+def _iter_regex_matches(text: str, rules: Iterable[_RegexRule], exclude: set[str]) -> list[_Match]:
+ found: list[_Match] = []
+ for rule in rules:
+ try:
+ r = re.compile(rule.pattern, _regex_flags_to_re(rule.flags))
+ except re.error:
+ # 无效正则:忽略(避免因为配置错误导致整个会话不可用)
+ continue
+ for m in r.finditer(text):
+ s, e = m.span(0)
+ if s < 0 or e <= s:
+ continue
+ original = text[s:e]
+ if original in exclude:
+ continue
+ found.append(_Match(start=s, end=e, original=original, category=rule.category))
+ return found
+
+
+def _redact_text(text: str, patterns: _PatternSet, session: _PlaceholderSession) -> tuple[str, list[_Match]]:
+ src = str(text or "")
+ if not src:
+ return src, []
+
+ found: list[_Match] = []
+ found.extend(_iter_keyword_matches(src, patterns.keywords, patterns.exclude))
+ found.extend(_iter_regex_matches(src, patterns.regex, patterns.exclude))
+
+ if not found:
+ return src, []
+
+ # 右侧优先;同起点优先更长(与 opencode-vibeguard 一致)
+ found.sort(key=lambda m: (m.start, m.end), reverse=True)
+
+ planned: list[_Match] = []
+ covered: list[tuple[int, int]] = []
+ for m in found:
+ segments = _subtract_covered(m.start, m.end, covered)
+ for s, e in segments:
+ if s < 0 or e > len(src) or s >= e:
+ continue
+ planned.append(_Match(start=s, end=e, original=src[s:e], category=m.category))
+ covered = _insert_covered(covered, (s, e))
+
+ planned.sort(key=lambda m: m.start, reverse=True)
+
+ out = src
+ for m in planned:
+ placeholder = session.get_or_create_placeholder(m.original, m.category)
+ out = out[: m.start] + placeholder + out[m.end :]
+ m.placeholder = placeholder
+
+ return out, planned
+
+
+def _format_category_counts(matches: list[_Match]) -> str:
+ counts: dict[str, int] = {}
+ for m in matches:
+ counts[m.category] = counts.get(m.category, 0) + 1
+ items = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
+ return ", ".join([f"{k}({v})" for k, v in items])
+
+
+def _emit_json(*, should_continue: bool, system_message: str = "", suppress_output: bool = True) -> None:
+ # 为了让 Claude Code 能可靠解析并执行“阻断/放行”,这里必须输出 JSON 到 stdout。
+ # 备注:systemMessage 既可用于向 Claude 注入上下文,也可能用于向用户展示拦截原因(取决于宿主实现)。
+ msg = str(system_message or "").strip()
+ payload: dict[str, Any] = {
+ "continue": bool(should_continue),
+ "suppressOutput": bool(suppress_output),
+ }
+ if msg:
+ payload["systemMessage"] = msg
+ payload["suppressOutput"] = False
+ print(json.dumps(payload, ensure_ascii=False), file=sys.stdout)
+
+
+def main() -> None:
+ # 重要:无论什么情况都输出 JSON(至少输出 {}),否则宿主会把输出当作纯文本,
+ # 导致 continue / systemMessage 等字段无法生效。
+ guard_cfg: _GuardConfig | None = None
+
+ try:
+ try:
+ input_data = json.load(sys.stdin)
+ except Exception:
+ input_data = {}
+
+ project_dir = (
+ os.environ.get("CLAUDE_PROJECT_DIR")
+ or str(input_data.get("cwd") or "").strip()
+ or str(input_data.get("project_dir") or "").strip()
+ )
+ if not project_dir:
+ project_dir = os.getcwd()
+
+ guard_cfg = _load_guard_config(project_dir)
+ if guard_cfg is None or not guard_cfg.enabled:
+ _emit_json(should_continue=True, suppress_output=True)
+ return
+
+ user_prompt = str(
+ input_data.get("user_prompt")
+ or input_data.get("userPrompt")
+ or input_data.get("prompt")
+ or ""
+ )
+ if not user_prompt.strip():
+ _emit_json(should_continue=True, suppress_output=True)
+ return
+
+ redact_cfg = _load_redact_config(project_dir, guard_cfg)
+ if redact_cfg is None or not redact_cfg.enabled:
+ if guard_cfg.fail_closed:
+ _emit_json(
+ should_continue=False,
+ system_message=(
+ "🔒 VibeGuard:已启用提示词防泄漏,但未找到可用的 redaction 配置(vibeguard.config.json)。\n"
+ "为避免误发送敏感信息,本次已阻止发送。\n\n"
+ "解决方法:\n"
+ "- 在项目根目录创建 vibeguard.config.json 并设置 enabled=true;或\n"
+ "- 在 .claude/vibeguard.local.md 中设置 redact_config 指向配置文件;或\n"
+ "- 临时关闭 guard_prompt。\n"
+ ),
+ )
+ return
+ _emit_json(should_continue=True, suppress_output=True)
+ return
+
+ session_id = str(input_data.get("session_id") or "").strip()
+ if session_id:
+ key = hashlib.sha256(f"claude-code-vibeguard:{session_id}".encode("utf-8")).digest()
+ else:
+ key = os.urandom(32)
+
+ session = _PlaceholderSession(prefix=redact_cfg.prefix, key=key)
+ redacted, matches = _redact_text(user_prompt, redact_cfg.patterns, session)
+ if not matches:
+ _emit_json(should_continue=True, suppress_output=True)
+ return
+
+ counts = _format_category_counts(matches)
+ if guard_cfg.action == "warn":
+ message = (
+ f"🔒 VibeGuard:检测到可能的敏感信息({len(matches)} 处;{counts})。\n"
+ "本次仅提醒(不会阻止发送)。为避免把明文泄漏给模型提供商,建议改用下面的“占位符替换版”文本重新发送(可直接复制):\n\n"
+ f"{redacted}\n"
+ )
+ _emit_json(should_continue=True, system_message=message)
+ return
+
+ message = (
+ f"🔒 VibeGuard:检测到可能的敏感信息({len(matches)} 处;{counts})。\n"
+ "已阻止发送以避免把明文泄漏给模型提供商。\n\n"
+ "建议改用下面的“占位符替换版”文本重新发送(可直接复制):\n\n"
+ f"{redacted}\n"
+ )
+ _emit_json(should_continue=False, system_message=message)
+ return
+
+ except Exception as e:
+ # 钩子出错时的兜底:优先不让用户误以为“已保护”,并尽量遵循 fail-closed。
+ # 注意:这里不要直接回显异常文本,避免把潜在敏感信息写进日志/界面。
+ should_block = bool(guard_cfg and guard_cfg.enabled and guard_cfg.fail_closed)
+ error_kind = type(e).__name__
+ _emit_json(
+ should_continue=not should_block,
+ system_message=(
+ "🔒 VibeGuard:提示词防泄漏钩子执行失败。\n"
+ f"错误类型:{error_kind}\n\n"
+ + (
+ "为避免误发送敏感信息,本次已阻止发送。\n"
+ "你可以临时关闭 guard_prompt,或修复配置/环境后再试。"
+ if should_block
+ else "为避免影响流程,本次未阻止发送。建议检查配置后重试。"
+ )
+ ),
+ )
+ return
+
+ finally:
+ # 必须始终 exit 0:避免因为钩子自身异常导致宿主把它当作“系统错误”
+ sys.exit(0)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/plugins/vibeguard/hooks/hooks.json b/plugins/vibeguard/hooks/hooks.json
new file mode 100644
index 000000000..8959ee800
--- /dev/null
+++ b/plugins/vibeguard/hooks/hooks.json
@@ -0,0 +1,17 @@
+{
+ "description": "VibeGuard plugin - prompt guard mode (no MITM)",
+ "hooks": {
+ "UserPromptSubmit": [
+ {
+ "matcher": "*",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks-handlers/user-prompt-submit.py",
+ "timeout": 2
+ }
+ ]
+ }
+ ]
+ }
+}