"""用户敏感信息管理(secret vault)—— 敏感信息入库 + 会话占位符注入 + 入站自动侦测。 设计目标(2026-09-17 用户需求): 1) 敏感信息入库:RC4 密文存 pipeline_user_secrets,按 org_id/user_id 隔离。 2) 会话中可"从表里选一个敏感信息插入会话":agent 只见元数据(名称/类型/前缀/长度), 插入的是占位符 `@@sec:NAME@@`,真值永不进模型上下文。 3) 入站自动侦测:用户消息/工具输出里出现明文敏感信息 → 自动入库 + 原文替换为占位符。 安全不变量(写死在代码,不靠 LLM 自觉): - **真值只在执行边界以「子进程环境变量」形式出现**,绝不拼进命令字符串 (拼进命令 = 进 tool_calls 落库 = 进模型上下文 = 每轮上行供应商)。 - 占位符语法 `@@sec:NAME@@`,刻意避开 `${X}$`(sqlor 参数化 / ahserver ArgsConvert 都用它,复用会被误展开)与 `$VAR`(bash 会就地展开,绕过我们的边界)。 - 任何对外返回/日志/审计都只带元数据,不带 encrypted_value 与明文。 - 未知占位符**原样保留**,绝不替换成空串(空串会静默改变命令语义, 实测 `rm -rf ${TARGET_DIR}$/` → `rm -rf /`)。 三个已修复的实现陷阱(挂调实锤,勿回退): A. 子串碰撞:长短两个 secret 共存时按插入序替换 → 长值被短值吃掉前缀、尾部明文裸露。 修复:替换前按 value 长度**降序**排序(_sorted_secrets)。 B. 占位符别名劫持:secret 的值本身长得像占位符 → 二次 token 化改写既有占位符。 修复:入库拒绝值内含占位符语法(_PLACEHOLDER_IN_VALUE);tokenize 幂等(已是占位符不重扫)。 C. 未知占位符吞空:见上,detokenize 一律原样保留。 """ import hashlib import logging import math import os import re from collections import Counter from typing import Dict, List, Optional, Tuple logger = logging.getLogger("pipeline.secret_vault") # ══════════════════════ 常量 ══════════════════════ TABLE = "pipeline_user_secrets" #: 占位符语法(模型可见、可回放;执行边界换成环境变量引用) PLACEHOLDER_PREFIX = "@@sec:" PLACEHOLDER_SUFFIX = "@@" #: 变量名白名单:只认大写字母开头的标识符(堵 eval/注入面、堵 shell 元字符)。 #: ⚠️ _PLACEHOLDER_RE 必须与本正则的**长度下界一致**(都是 1 字符起)—— #: 曾写成 {1,62}(即最少 2 字符)导致单字符名能入库、但 find_placeholders/ #: detokenize 识别不出 → 未知占位符漏报。改名字规则时两处同步(挂调实锤)。 _NAME_RE = re.compile(r"^[A-Z][A-Z0-9_]{0,62}$") _PLACEHOLDER_RE = re.compile(r"@@sec:([A-Z][A-Z0-9_]{0,62})@@") #: 执行边界注入子进程的环境变量前缀(agent 写 $PIPELINE_SEC_GITHUB_TOKEN) ENV_PREFIX = "PIPELINE_SEC_" #: 占位符在值里出现即拒绝入库(防陷阱 B) _PLACEHOLDER_IN_VALUE = re.compile(r"@@sec:") #: 已知凭据前缀(高置信度,直接判定为敏感;与 Hermes agent/redact.py 同源思路) KNOWN_PREFIXES = [ (r"sk-[A-Za-z0-9_\-]{10,}", "openai_style_key"), (r"github_pat_[A-Za-z0-9_]{10,}", "github_pat"), (r"ghp_[A-Za-z0-9]{10,}", "github_token"), (r"gh[osur]_[A-Za-z0-9]{10,}", "github_oauth"), (r"glpat-[A-Za-z0-9_\-]{10,}", "gitlab_pat"), (r"xox[baprs]-[A-Za-z0-9\-]{10,}", "slack_token"), (r"AIza[A-Za-z0-9_\-]{30,}", "google_api_key"), (r"AKIA[A-Z0-9]{16}", "aws_access_key"), (r"sk_live_[A-Za-z0-9]{10,}", "stripe_live_key"), (r"sk_test_[A-Za-z0-9]{10,}", "stripe_test_key"), (r"hf_[A-Za-z0-9]{10,}", "huggingface_token"), (r"npm_[A-Za-z0-9]{10,}", "npm_token"), (r"pypi-[A-Za-z0-9_\-]{10,}", "pypi_token"), (r"plk-[A-Za-z0-9]{8,}", "platform_short_token"), (r"rak-[A-Za-z0-9]{8,}", "platform_api_key"), (r"-----BEGIN[A-Z ]*PRIVATE KEY-----", "private_key"), ] _KNOWN_RES = [(re.compile(p), t) for p, t in KNOWN_PREFIXES] #: 关键词邻域形态:`apikey: xxx` / `api_key=xxx` / `token:xxx` / `Authorization: Bearer ***` #: 中英文冒号都认(实测 ASCII `=` 能命中而中文 `:` 漏网是 Hermes 侧的真实缺口,这里补齐) _KEYWORD_RE = re.compile( r"(api[_\-\s]?key|apikey|access[_\-\s]?token|secret[_\-\s]?key|auth[_\-\s]?token" r"|bearer|passwd|password|private[_\-\s]?key|credential)" r"\s*[::=]?\s*[\"']?([A-Za-z0-9_\-.+/]{12,128})[\"']?", re.IGNORECASE, ) #: 低熵/示例值黑名单(防把 `password: 123456`、`sk-xxx` 占位符当凭据吞掉) _JUNK_VALUE_RE = re.compile( r"^(?:x{3,}|\*{3,}|\.{3}|xxx+|your[_\-]?[\w]*|example[\w]*|placeholder|dummy|test[\w]*" r"|none|null|true|false|\d{1,8}|[a-z]{1,12}|[\w]*\[[\w]*\]?)$", re.IGNORECASE, ) #: 自动侦测的熵门限:低于此值不自动入库(宁可漏也不误吞日常文本) ENTROPY_THRESHOLD = 3.0 #: 自动入库的最短长度 MIN_AUTO_LEN = 12 #: 类型猜测用 _TYPE_GUESS = [ ("github", "github_token"), ("gitlab", "gitlab_token"), ("slack", "slack_token"), ("aws", "aws_key"), ("stripe", "stripe_key"), ("openai", "openai_key"), ("dashscope", "dashscope_key"), ("aliyun", "aliyun_key"), ("db", "db_password"), ("mysql", "db_password"), ("redis", "db_password"), ("ssh", "ssh_key"), ("token", "token"), ("key", "api_key"), ("secret", "secret"), ("pass", "password"), ("pwd", "password"), ] # ══════════════════════ 加解密(AES,2026-09-17 用户指定替换 RC4) ══════════════ # # 为什么不用 ahserver 的 password_encode/password_decode:实测 globalEnv.py:61 # `from appPublic.rc4 import password, unpassword` —— 它们就是 RC4 的薄封装, # 换过去等于没换。真正的 AES 在 appPublic.aes(aes_encode_b64 / aes_decode_b64, # AES-ECB + PKCS7 + base64),与 DB 连接密码(config.json databases.*.password)同套。 # # ⚠️ aes.py 内部有**两处方向相反**的 iso-8859-1,只有一处需要包装(2026-09-17 实证): # ① 密文 bytes → str(aes.py:42-43, 46-47):**必须** iso-8859-1,不能换 utf-8。 # 实测 200 组 AES 密文用 utf-8 解码失败 200 组(100%)——密文是二进制, # 不符合 UTF-8 编码规范(首字节 0xef 即 invalid continuation byte)。 # 这处是历史正确设计(曾用 utf-8 撞编解码错误后改的),**不要动**。 # ② 明文 str → bytes(aes.py:29, 37):iso-8859-1 表示不了非 ASCII, # 实测「密码是中文Abc123」「🔑key123」直接 UnicodeEncodeError。 # 凭据含非 ASCII 很常见(中文口令、含中文的备注型凭据),所以在**调用方**包装: # str → UTF-8 bytes → 按 latin-1 逐字节还原成等长 str 传入(latin-1 是字节保真 # 映射,0x00-0xFF 一一对应),aes.py:29 再 encode('iso-8859-1') 即还原成 # 原始 UTF-8 字节流 = 字节保真空操作;出库反向还原。 # 不改 aes.py(appPublic 是多宿主共享基础模块,改它影响 DB 密码等全部调用方)。 # 包装后实测 9/9 往返一致:ASCII / GitHub PAT / 中文 / emoji / 中英混合+符号 / # 私钥含换行 / 512 长值 / 特殊字符 p@$$w0rd!#%^&*() / 含 @@sec: 语法的值。 # # 密文加版本前缀 AES1$ —— 因为 base64 密文没有固定形态特征,无法像 RC4 的 # QUZVcX 那样靠"前缀嗅探"判断是否已加密;显式版本标记同时为将来算法迁移留路。 #: 密文版本前缀(AES-ECB + UTF-8 包装 + base64) _AES_PREFIX = "AES1$" #: 历史 RC4 密文前缀(本平台 password() 产出;用于向后兼容读取,不再写入) _RC4_PREFIX = "QUZVcX" def _password_key() -> str: """宿主 password_key(pipeline-app 默认值兜底)。""" try: from appPublic.jsonConfig import getConfig return getConfig().password_key or "QRIVSRHrthhwyjy176556332" except Exception: return "QRIVSRHrthhwyjy176556332" def encrypt_secret(plain: str) -> str: """AES 加密 + UTF-8 包装,产出带版本前缀的密文。 幂等保护:已是密文形态(AES1$ 或历史 RC4 QUZVcX)则原样返回,防多层叠加—— 多层加密是历史真实事故(llm.api_key 叠 2~3 层 → 解出来还是密文 → 上游 401)。 """ if not plain: return "" if looks_encrypted(plain): return plain from appPublic.aes import aes_encode_b64 body = aes_encode_b64(_password_key(), plain.encode("utf-8").decode("iso-8859-1")) return _AES_PREFIX + body def decrypt_secret(enc: str) -> str: """解密。按前缀分派:AES1$ → AES;QUZVcX → 历史 RC4 兜底;其余 → 原文返回。 失败返回原文(不抛异常):调用方拿到不可用值会自然失败并报错, 比抛异常中断整条会话链更可控。 """ if not enc: return "" key = _password_key() try: if enc.startswith(_AES_PREFIX): from appPublic.aes import aes_decode_b64 raw = aes_decode_b64(key, enc[len(_AES_PREFIX):]) return raw.encode("iso-8859-1").decode("utf-8") if enc.startswith(_RC4_PREFIX): # 历史 RC4 密文(本模块早期版本或 llm 表迁移过来的值) from appPublic.rc4 import unpassword return unpassword(enc, key) return enc except Exception as e: logger.warning("decrypt_secret failed: %s", str(e)[:120]) return enc def looks_encrypted(value: str) -> bool: """是否已是密文形态(AES1$ 版本前缀,或历史 RC4 的 QUZVcX 前缀)。""" if not value: return False return value.startswith(_AES_PREFIX) or value.startswith(_RC4_PREFIX) # ══════════════════════ 纯函数:指纹 / 熵 / 类型 ══════════════════════ def fingerprint(plain: str) -> str: """明文 sha256(用于精确去重 + 入站精确匹配,比正则可靠)。""" return hashlib.sha256((plain or "").encode("utf-8", "replace")).hexdigest() def shannon_entropy(s: str) -> float: if not s: return 0.0 c = Counter(s) n = len(s) return -sum((v / n) * math.log2(v / n) for v in c.values()) def guess_type(name_hint: str = "", value: str = "") -> str: """按值形态/名称猜类型(只做展示分类,不做安全判定)。 **值形态优先于名称关键词**:值的前缀是硬证据(`sk-proj-…` 就是 OpenAI 形态), 名称提示是软线索。反序会让精确类型被泛化词覆盖——实测 name_hint `openai_style_key` 里的 `key` 命中关键词表,把类型降级成 `api_key`(挂调实锤)。 """ v = value or "" for rx, t in _KNOWN_RES: if rx.search(v): return t hay = ((name_hint or "") + " " + v[:24]).lower() for kw, t in _TYPE_GUESS: if kw in hay: return t return "other" def mask_preview(plain: str) -> Tuple[str, int]: """返回 (前缀提示, 长度)。**只取前 4 字符**——够人类辨认是哪个凭据, 不足以被拼接复用(实测 GitHub PAT 前缀 `gith`/`ghp_` 是公开固定值,零熵)。""" if not plain: return "", 0 return plain[:4], len(plain) def normalize_name(raw: str) -> str: """变量名归一:转大写、非标识符字符转下划线、限长。不合法返回空串。""" n = re.sub(r"[^A-Za-z0-9_]", "_", (raw or "").strip()).upper() n = re.sub(r"_+", "_", n).strip("_")[:63] if not n: return "" if not n[0].isalpha(): n = "S_" + n return n if _NAME_RE.match(n) else "" def make_placeholder(name: str) -> str: return PLACEHOLDER_PREFIX + name + PLACEHOLDER_SUFFIX # ══════════════════════ 侦测(纯函数,无 DB) ══════════════════════ def detect_candidates(text: str) -> List[Dict]: """扫描文本,返回疑似敏感信息候选(去重,按置信度排序)。 三条通道: ① 已知凭据前缀(高置信度,无视熵) ② 关键词邻域 + 熵门限(`apikey: xxx`、`Authorization: Bearer xxx`) ③ 不做无上下文裸串侦测(误报会静默毁数据,比漏更糟) 返回项:{value, name_suggest, secret_type, confidence, source, entropy} """ if not text or not isinstance(text, str): return [] out: Dict[str, Dict] = {} def _add(value: str, conf: str, source: str, name_hint: str = ""): value = (value or "").strip().strip("\"'`,;") if len(value) < MIN_AUTO_LEN or len(value) > 256: return if _JUNK_VALUE_RE.match(value): return ent = shannon_entropy(value) # 已知前缀是强证据,熵门限只对关键词通道生效 if source != "known_prefix" and ent < ENTROPY_THRESHOLD: return fp = fingerprint(value) if fp in out: # 同一值多通道命中 → 提升置信度 if conf == "high": out[fp]["confidence"] = "high" return out[fp] = { "value": value, "fingerprint": fp, "entropy": round(ent, 2), "confidence": conf, "source": source, "name_suggest": normalize_name(name_hint) or _suggest_name(value, name_hint), "secret_type": guess_type(name_hint, value), } # ① 已知前缀 for rx, t in _KNOWN_RES: for m in rx.finditer(text): v = m.group(0) # 私钥块整段不入 value(太长),只标记类型 if v.startswith("-----BEGIN"): continue _add(v, "high", "known_prefix", t) # ② 关键词邻域 for m in _KEYWORD_RE.finditer(text): kw, val = m.group(1), m.group(2) if not val: continue _add(val, "high" if shannon_entropy(val) >= 3.5 else "medium", "keyword", kw) ranked = sorted(out.values(), key=lambda d: (d["confidence"] != "high", -d["entropy"])) return ranked def _suggest_name(value: str, hint: str = "") -> str: """从关键词/值形态派生变量名(确定性,同名不同值时调用方负责加序号)。""" base = normalize_name(hint) if hint else "" if not base: base = normalize_name(guess_type("", value)) if not base or base == "OTHER": base = "SECRET" if not base.startswith("SEC_"): base = "SEC_" + base return base[:63] # ══════════════════════ 占位符替换(tokenize / detokenize) ══════════════════════ def _sorted_secrets(secrets: Dict[str, str]) -> List[Tuple[str, str]]: """陷阱 A 修复:按值长度降序,长值先替换,防短值是长值子串时吃掉前缀。""" return sorted(((n, v) for n, v in secrets.items() if v), key=lambda kv: -len(kv[1])) def tokenize(text: str, secrets: Dict[str, str]) -> Tuple[str, List[str]]: """把文本里出现的**已知 secret 明文**替换成占位符。 Args: text: 待净化文本(用户消息 / 工具输出) secrets: {NAME: 明文值}(只应传当前用户可见的 secret) Returns: (净化后文本, 命中的 NAME 列表) 幂等:已是占位符的片段不含明文,自然不会被再处理(陷阱 B 的一半)。 """ if not text: return text, [] hits: List[str] = [] for name, value in _sorted_secrets(secrets): if value in text: text = text.replace(value, make_placeholder(name)) hits.append(name) return text, hits def detokenize(text: str, secrets: Dict[str, str], *, mode: str = "env") -> Tuple[str, List[str], List[str]]: """把占位符还原成**可执行形态**。 mode="env"(默认,推荐):占位符 → `$PIPELINE_SEC_`(shell 变量引用), 真值不进字符串,由调用方通过环境变量注入子进程。 mode="value"(仅限非 shell 场景,如 HTTP header 构造):占位符 → 明文值。 ⚠️ 用这个模式的调用方必须保证结果不落库、不进模型上下文、不打日志。 Returns: (替换后文本, 命中的 NAME 列表, 未知占位符列表) 陷阱 C 修复:未知占位符**原样保留**(返回在第三个元素里让调用方决定报错), 绝不替换成空串。 """ if not text: return text, [], [] hits: List[str] = [] unknown: List[str] = [] def _repl(m): name = m.group(1) if name not in secrets: unknown.append(name) return m.group(0) # 原样保留 hits.append(name) if mode == "value": return secrets[name] return "$" + ENV_PREFIX + name return _PLACEHOLDER_RE.sub(_repl, text), hits, unknown def find_placeholders(text: str) -> List[str]: """列出文本中引用的占位符名(未知与否都列,供权限校验)。""" return [m.group(1) for m in _PLACEHOLDER_RE.finditer(text or "")] def resolve_env(secrets: Dict[str, str], names: List[str]) -> Dict[str, str]: """为子进程构造环境变量字典(只含被引用到的 secret,最小暴露面)。""" env: Dict[str, str] = {} for n in names: if n in secrets: env[ENV_PREFIX + n] = secrets[n] return env # ══════════════════════ DB 层 ══════════════════════ def _get_db(): from sqlor.dbpools import DBPools db = DBPools() if not db.databases: from appPublic.jsonConfig import getConfig cfg = getConfig() if cfg.databases: db.databases = cfg.databases return db _META_COLS = ("id, name, label, secret_type, fingerprint, prefix_hint, length_hint, " "org_id, user_id, source, status, remark, use_count, last_used_at, " "created_at, updated_at") async def list_secrets(sor, org_id: str = "", user_id: str = "", only_active: bool = True) -> List[Dict]: """列出可见 secret 的**元数据**(绝不返回 encrypted_value / 明文)。 可见范围:本人(user_id 归属)+ 本机构共享(org_id 归属且 user_id 为空的机构级条目)。 """ sql = ("SELECT " + _META_COLS + " FROM " + TABLE + " WHERE (user_id=${u}$ OR (org_id=${o}$ AND user_id='')) ") if only_active: sql += "AND status='active' " sql += "ORDER BY created_at DESC LIMIT 200" recs = await sor.sqlExe(sql, {"u": user_id or "", "o": org_id or ""}) return [_rec_to_meta(r) for r in (recs or [])] def _rec_to_meta(r) -> Dict: d = dict(r) d.pop("encrypted_value", None) # 双保险:即使 SELECT 误带也不外泄 return d def _assert_own(row: Optional[Dict], user_id: str) -> bool: """写操作前的归属硬门禁(纵深防御,2026-09-17)。 get_secret_row(own_only=True) 的 SQL WHERE 已按 user_id 精确过滤,正常情况下 机构共享行(user_id='')与他人行根本查不出来。但**不依赖单一防线**: 将来若有人改 SQL、加新查询路径、或复用 own_only=False 的读路径去写, 这层代码级校验仍能挡住越权 UPDATE/DELETE(不可逆操作,符合「入口硬门禁」铁律)。 返回 True = 该行确属调用者本人,允许写。 """ if not row: return False owner = str(row.get("user_id") or "") return bool(user_id) and owner == str(user_id) async def get_secret_row(sor, secret_id: str = "", name: str = "", org_id: str = "", user_id: str = "", own_only: bool = False) -> Optional[Dict]: """按 id 或 name 取单行(含密文,内部用)。 权限范围(2026-09-17 修复越权写入漏洞): - own_only=False(**读/用**路径):本人条目 + 本机构共享条目(user_id='')。 机构共享凭据就是给成员用的,读范围必须含它。 - own_only=True(**写**路径:save/delete/set_status):只匹配本人条目。 否则任意机构成员都能 UPDATE/DELETE 机构共享凭据(按名查到共享行就直接改), 等于全机构凭据可被任一成员覆盖或删除。机构共享条目由管理侧(CRUD 页/管理员) 维护,agent 工具与用户自助路径一律只能动自己的。 """ scope = ("AND user_id=${u}$" if own_only else "AND (user_id=${u}$ OR (org_id=${o}$ AND user_id=''))") if secret_id: sql = "SELECT * FROM " + TABLE + " WHERE id=${i}$ " + scope + " LIMIT 1" ns = {"i": secret_id, "u": user_id or "", "o": org_id or ""} elif name: sql = "SELECT * FROM " + TABLE + " WHERE name=${n}$ " + scope + " LIMIT 1" ns = {"n": name, "u": user_id or "", "o": org_id or ""} else: return None recs = await sor.sqlExe(sql, ns) return dict(recs[0]) if recs else None async def _name_taken(sor, name: str, org_id: str, user_id: str, exclude_fp: str = "") -> bool: """同名是否已被**不同值**占用(同值同名 = 幂等,不算冲突)。 只看本人命名空间(own_only):机构共享条目占用了某个名字,不应阻止用户建同名的 个人条目——个人条目优先级更高(load_visible_secrets 里同名会各自返回,但使用方 按名取到的是本人那份),且唯一索引 (org_id,user_id,name) 允许两者共存。 """ sql = ("SELECT id, fingerprint FROM " + TABLE + " WHERE name=${n}$ AND user_id=${u}$ LIMIT 1") recs = await sor.sqlExe(sql, {"n": name, "u": user_id or ""}) if not recs: return False return (getattr(recs[0], "fingerprint", "") or "") != exclude_fp async def unique_name(sor, base: str, org_id: str, user_id: str, fp: str) -> str: """名字冲突时加序号(SEC_APIKEY → SEC_APIKEY_2 …),确定性不随机。""" name = normalize_name(base) or "SEC_SECRET" for i in range(2, 50): if not await _name_taken(sor, name, org_id, user_id, fp): return name name = normalize_name(base) [:58] + "_" + str(i) return name[:58] + "_" + str(hashlib.sha1(fp.encode()).hexdigest()[:4]).upper() async def save_secret(sor, *, name: str, value: str, org_id: str = "", user_id: str = "", label: str = "", secret_type: str = "", source: str = "manual", remark: str = "", who: str = "") -> Dict: """入库(新增或更新同指纹条目)。返回 {ok, name, action, message, meta}。 幂等:同 (org,user,name) 已存在且指纹相同 → 直接返回 existing,不重复写。 """ from appPublic.uniqueID import getID from .audit import record_audit name = normalize_name(name) if not name: return {"ok": False, "message": "FAIL: 变量名不合法(须字母开头,只含大写字母/数字/下划线)"} if not value: return {"ok": False, "message": "FAIL: 值不能为空"} if _PLACEHOLDER_IN_VALUE.search(value): # 陷阱 B:值本身含占位符语法 → 二次 token 化会改写既有占位符 return {"ok": False, "message": "FAIL: 值内含占位符语法 @@sec:,已拒绝入库(会破坏替换)"} fp = fingerprint(value) prefix_hint, length_hint = mask_preview(value) secret_type = secret_type or guess_type(name, value) enc = encrypt_secret(value) # own_only=True:只查本人条目。否则按名查到机构共享条目(user_id='')就直接 UPDATE, # 任意机构成员都能覆盖全机构共用的凭据(2026-09-17 自我 review 抓出的越权漏洞)。 # 机构共享条目与本人同名时可共存(唯一索引含 user_id),本人那份优先。 existing = await get_secret_row(sor, name=name, org_id=org_id, user_id=user_id, own_only=True) # 纵深防御:即便查询返回了非本人行(SQL 被改/复用错路径),也不得 UPDATE if existing and not _assert_own(existing, user_id): logger.warning("save_secret blocked: name=%s row owner mismatch", name) existing = None # 当作不存在 → 走 INSERT 本人新行,不碰他人/共享行 if existing: if (existing.get("fingerprint") or "") == fp: return {"ok": True, "action": "existing", "name": name, "message": "该名称下已是同一份敏感信息,无需重复保存。", "meta": {k: v for k, v in existing.items() if k != "encrypted_value"}} await sor.sqlExe( "UPDATE " + TABLE + " SET encrypted_value=${e}$, fingerprint=${f}$, " "prefix_hint=${p}$, length_hint=${l}$, secret_type=${t}$, label=${lb}$, " "remark=${rm}$, status='active', updated_at=NOW() WHERE id=${i}$", {"e": enc, "f": fp, "p": prefix_hint, "l": length_hint, "t": secret_type, "lb": label or existing.get("label") or name, "rm": remark or "", "i": existing["id"]}) action, sid = "rotated", existing["id"] else: sid = getID() await sor.sqlExe( "INSERT INTO " + TABLE + " (id, name, label, secret_type, encrypted_value, " "fingerprint, prefix_hint, length_hint, org_id, user_id, source, status, " "remark, use_count, created_at, updated_at) VALUES " "(${i}$, ${n}$, ${lb}$, ${t}$, ${e}$, ${f}$, ${p}$, ${l}$, ${o}$, ${u}$, " "${s}$, 'active', ${rm}$, 0, NOW(), NOW())", {"i": sid, "n": name, "lb": label or name, "t": secret_type, "e": enc, "f": fp, "p": prefix_hint, "l": length_hint, "o": org_id or "", "u": user_id or "", "s": source or "manual", "rm": remark or ""}) action = "created" try: await record_audit(org_id or user_id or "", TABLE, sid, "secret_" + action, who=who or user_id or "agent", detail="name=" + name + " type=" + secret_type + " source=" + (source or "")) except Exception as e: logger.warning("audit failed on save_secret: %s", str(e)[:120]) return {"ok": True, "action": action, "name": name, "id": sid, "message": ("已保存敏感信息 " + name + "(密文入库,值不会显示给任何人)。" if action == "created" else "已更新 " + name + " 的值(旧值已覆盖)。"), "meta": {"name": name, "secret_type": secret_type, "prefix_hint": prefix_hint, "length_hint": length_hint, "source": source}} async def delete_secret(sor, *, name: str = "", secret_id: str = "", org_id: str = "", user_id: str = "", who: str = "") -> Dict: """删除(**仅本人条目**)。 own_only=True:机构共享条目不可由普通成员删除——原实现的 docstring 声称 「用 user_id 过滤天然满足」是错的,因为 SQL 的 scope 是 `user_id=${u}$ OR (org_id=${o}$ AND user_id='')`,机构共享行照样被查中并删掉。 """ from .audit import record_audit row = await get_secret_row(sor, secret_id=secret_id, name=name, org_id=org_id, user_id=user_id, own_only=True) if not row or not _assert_own(row, user_id): # 纵深防御:DELETE 不可逆,归属校验失败一律拒绝(含机构共享条目) if row: logger.warning("delete_secret blocked: name=%s owner mismatch", name) return {"ok": False, "message": "FAIL: 找不到该敏感信息(或不属于你本人)"} await sor.sqlExe("DELETE FROM " + TABLE + " WHERE id=${i}$", {"i": row["id"]}) try: await record_audit(org_id or user_id or "", TABLE, row["id"], "secret_deleted", who=who or user_id or "agent", detail="name=" + row.get("name", "")) except Exception: pass return {"ok": True, "message": "已删除 " + row.get("name", "")} async def update_secret_meta(sor, *, name: str, label: str = "", remark: str = "", org_id: str = "", user_id: str = "", who: str = "") -> Dict: """改标签/备注(**仅本人条目**;值不可改——改值走删除重存,避免指纹/审计歧义)。 管理页(2026-09-18)用;与 set_secret_status 同款 own_only + _assert_own 双防线。 """ from .audit import record_audit row = await get_secret_row(sor, name=name, org_id=org_id, user_id=user_id, own_only=True) if not row or not _assert_own(row, user_id): return {"ok": False, "message": "FAIL: 找不到该敏感信息(或不属于你本人)"} await sor.sqlExe( "UPDATE " + TABLE + " SET label=${l}$, remark=${r}$, updated_at=NOW() WHERE id=${i}$", {"l": label or "", "r": remark or "", "i": row["id"]}) try: await record_audit(org_id or user_id or "", TABLE, row["id"], "secret_meta_update", who=who or user_id or "agent", detail="name=" + name) except Exception: pass return {"ok": True, "message": name + " 元数据已更新"} async def set_secret_status(sor, *, name: str, status: str, org_id: str = "", user_id: str = "", who: str = "") -> Dict: """启用/停用(active / disabled)。停用后不再注入执行环境。""" from .audit import record_audit if status not in ("active", "disabled"): return {"ok": False, "message": "FAIL: status 须为 active 或 disabled"} # own_only=True:停用机构共享凭据会影响全机构成员,只能管理侧操作 row = await get_secret_row(sor, name=name, org_id=org_id, user_id=user_id, own_only=True) if not row or not _assert_own(row, user_id): # 纵深防御:停用机构共享凭据会影响全机构成员,归属不符一律拒绝 if row: logger.warning("set_secret_status blocked: name=%s owner mismatch", name) return {"ok": False, "message": "FAIL: 找不到该敏感信息(或不属于你本人)"} await sor.sqlExe("UPDATE " + TABLE + " SET status=${s}$, updated_at=NOW() WHERE id=${i}$", {"s": status, "i": row["id"]}) try: await record_audit(org_id or user_id or "", TABLE, row["id"], "secret_" + status, who=who or user_id or "agent", detail="name=" + name) except Exception: pass return {"ok": True, "message": name + " 已" + ("启用" if status == "active" else "停用")} async def load_visible_secrets(sor, org_id: str = "", user_id: str = "") -> Dict[str, str]: """加载可见 secret 的 {NAME: 明文}(**仅执行边界内部使用**,禁止返回给 LLM/落日志)。""" recs = await sor.sqlExe( "SELECT name, encrypted_value FROM " + TABLE + " WHERE status='active' AND (user_id=${u}$ OR (org_id=${o}$ AND user_id='')) LIMIT 200", {"u": user_id or "", "o": org_id or ""}) out: Dict[str, str] = {} for r in (recs or []): n = getattr(r, "name", "") or "" e = getattr(r, "encrypted_value", "") or "" if n and e: out[n] = decrypt_secret(e) return out async def touch_usage(sor, names: List[str], org_id: str = "", user_id: str = "") -> None: """记录使用(use_count + last_used_at)。失败不阻断执行。""" for n in names or []: try: await sor.sqlExe( "UPDATE " + TABLE + " SET use_count=use_count+1, last_used_at=NOW(), " "updated_at=NOW() WHERE name=${n}$ AND " "(user_id=${u}$ OR (org_id=${o}$ AND user_id=''))", {"n": n, "u": user_id or "", "o": org_id or ""}) except Exception: pass # ══════════════════════ 入站门禁(自动侦测 + 入库 + 替换) ══════════════════════ async def scan_and_capture(sor, text: str, *, org_id: str = "", user_id: str = "", who: str = "", auto_store: bool = True) -> Dict: """入站净化:侦测文本中的敏感信息 → 自动入库 → 原文替换为占位符。 Returns: { "text": 净化后的文本(进模型上下文的就是它), "actions": [人类可读的动作说明], "captured": [{"name","secret_type","confidence","source"}], "known_hits": [NAME], # 命中已入库 secret 的明文(不新增) "unknown_names": [], # 文本里引用了不存在的占位符(提示用户) } 两层替换,顺序固定: ① 已入库 secret 的明文 → 占位符(精确值匹配,零误报) ② 新侦测到的高置信度候选 → 自动入库 + 替换(熵门限 + 垃圾值黑名单) """ result = {"text": text or "", "actions": [], "captured": [], "known_hits": [], "unknown_names": []} if not text: return result # 文本里已有的占位符引用:先校验存在性(未知占位符要告诉用户,不静默) referenced = find_placeholders(text) if referenced: known = set() try: known = set(await load_visible_secrets(sor, org_id, user_id)) except Exception as e: logger.warning("scan_and_capture load failed: %s", str(e)[:120]) result["unknown_names"] = [n for n in referenced if n not in known] if result["unknown_names"]: result["actions"].append( "引用了不存在的敏感信息:" + ", ".join(result["unknown_names"]) + "(已原样保留,执行时不会替换成空值)") # ① 已入库明文 → 占位符 try: secrets = await load_visible_secrets(sor, org_id, user_id) except Exception as e: logger.warning("load_visible_secrets failed: %s", str(e)[:120]) secrets = {} cleaned, hits = tokenize(result["text"], secrets) result["text"] = cleaned result["known_hits"] = hits if hits: result["actions"].append( "检测到已入库的敏感信息明文,已替换为占位符:" + ", ".join(hits)) # ② 新候选 → 自动入库 if not auto_store: return result candidates = detect_candidates(result["text"]) for cand in candidates: if cand["confidence"] != "high": continue # 中低置信度不自动入库(只提示,见下) val = cand["value"] fp = cand["fingerprint"] base = cand["name_suggest"] or _suggest_name(val) try: name = await unique_name(sor, base, org_id, user_id, fp) r = await save_secret(sor, name=name, value=val, org_id=org_id, user_id=user_id, secret_type=cand["secret_type"], source="auto_detect", who=who, remark="会话入站自动侦测(" + cand["source"] + ",熵 " + str(cand["entropy"]) + ")") except Exception as e: logger.warning("auto capture failed: %s", str(e)[:160]) continue if not r.get("ok"): result["actions"].append("自动保存 " + base + " 失败:" + r.get("message", "")) continue name = r.get("name", base) # 用真值替换(此时 result["text"] 里仍是明文) result["text"] = result["text"].replace(val, make_placeholder(name)) result["captured"].append({"name": name, "secret_type": cand["secret_type"], "confidence": cand["confidence"], "source": cand["source"], "action": r.get("action")}) result["actions"].append( "自动侦测到敏感信息(类型 " + cand["secret_type"] + ")→ 已密文入库为 " + name + " → 消息中已替换为占位符 @@sec:" + name + "@@。" "执行命令时用 $" + ENV_PREFIX + name + " 引用,真值不会出现在对话里。") # 中置信度候选只提示不自动入库(避免误吞;用户可显式 save_secret) medium = [c for c in candidates if c["confidence"] != "high"] if medium: result["actions"].append( "另有 " + str(len(medium)) + " 处疑似敏感信息置信度不足,未自动入库" "(如需保存请说「把 XXX 存为敏感信息 YYY」)。") return result # ══════════════════════ 出站:工具输出净化 ══════════════════════ async def sanitize_tool_output(sor, text: str, *, org_id: str = "", user_id: str = "") -> Tuple[str, List[str]]: """工具输出净化(需求2的"执行返回当输入处理")。 只做**已入库 secret 的精确值擦洗**——不做自动侦测入库(工具输出里的疑似串 误报代价高:会把哈希/base64 数据块当凭据吞掉,静默毁数据)。 高熵未知串只在日志里 WARN 提示,不改写。 """ if not text: return text, [] try: secrets = await load_visible_secrets(sor, org_id, user_id) except Exception as e: logger.warning("sanitize_tool_output load failed: %s", str(e)[:120]) return text, [] cleaned, hits = tokenize(text, secrets) if hits: logger.info("tool output scrubbed secrets: %s", ",".join(hits)) return cleaned, hits # ══════════════════════ 执行边界:占位符 → 环境变量 ══════════════════════ #: env 引用形态($PIPELINE_SEC_NAME / ${PIPELINE_SEC_NAME})——prompt 承诺的写法 _ENV_REF_RE = re.compile(r"\$\{?" + ENV_PREFIX + r"([A-Z][A-Z0-9_]*)\}?") async def prepare_command(sor, command: str, *, org_id: str = "", user_id: str = "") -> Tuple[str, Dict[str, str], List[str]]: """执行边界唯一入口:命令里的占位符 → `$PIPELINE_SEC_NAME` + 环境变量字典。 Returns: (改写后的命令, 需注入子进程的环境变量, 未知占位符列表) 设计:真值**不进命令字符串**——命令里只有变量名,值走 env 注入子进程。 这样命令原文落库/进模型上下文都不含明文(对齐 Hermes 实测的安全形态: `T=$(cat ~/.hermes/scripts/.github_token); curl -H "Authorization: Bearer ***"`)。 """ if not command: return command, {}, [] try: secrets = await load_visible_secrets(sor, org_id, user_id) except Exception as e: logger.warning("prepare_command load failed: %s", str(e)[:120]) secrets = {} rewritten, hits, unknown = detokenize(command, secrets, mode="env") # env 引用形态补齐(2026-09-17 缺口修复,e2e 实锤):SECRET_PROMPT_BLOCK 承诺 # 「命令里写 $PIPELINE_SEC_X 平台执行瞬间注入真值」,但此前只认占位符形态—— # agent 照 prompt 写 env 引用时 hits 为空 → env 不注入 → 变量展开为空 # (实测 stdout `SEC_READY_` 空值)。两种写法从此等价: # 已存在名称 → 进 hits(env 注入);不存在名称 → 进 unknown(拒绝执行, # 与陷阱 C「绝不静默空值」同原则——空展开会静默改变命令语义)。 for m in _ENV_REF_RE.finditer(rewritten): n = m.group(1) if n in secrets: if n not in hits: hits.append(n) elif n not in unknown: unknown.append(n) env = resolve_env(secrets, hits) if hits: try: await touch_usage(sor, hits, org_id, user_id) except Exception: pass return rewritten, env, unknown # ══════════════════════ 会话工具(agent 可见) ══════════════════════ # handler 签名对齐 pipeline_service 能力包约定:async def h(sor, params, ctx) -> str def _ctx_ids(ctx: Dict) -> Tuple[str, str]: """从 ctx 取 (org_id, user_id)。ctx 可能缺字段,一律兜空串。""" return (ctx or {}).get("org_id") or "", (ctx or {}).get("user_id") or "" def _fmt_meta_list(rows: List[Dict]) -> str: if not rows: return ("你还没有保存任何敏感信息。\n" "用法:直接说「把 apikey xxx 存为 GITHUB_TOKEN」,或在消息里带上明文——" "我会自动侦测、密文入库并替换成占位符。") lines = ["可用敏感信息(值已加密,任何人包括你自己都看不到明文;用 $PIPELINE_SEC_<名称> 在命令里引用):"] for r in rows: lines.append( "- " + str(r.get("name", "")) + " [类型:" + str(r.get("secret_type", "") or "other") + " 前缀:" + str(r.get("prefix_hint", "") or "-") + " 长度:" + str(r.get("length_hint", 0)) + " 来源:" + str(r.get("source", "") or "manual") + " 用过:" + str(r.get("use_count", 0)) + "次" + (" 备注:" + str(r.get("remark", "")) if r.get("remark") else "") + "]") lines.append("") lines.append("插入会话用占位符 @@sec:<名称>@@;执行命令时写 $PIPELINE_SEC_<名称>,") lines.append("平台会在执行瞬间注入真值到子进程环境变量,对话与数据库里都不会出现明文。") return "\n".join(lines) async def h_list_secrets(sor, params: Dict, ctx: Dict) -> str: org_id, user_id = _ctx_ids(ctx) if not user_id: return "FAIL: 当前会话没有用户身份(无人值守场景不提供敏感信息)" rows = await list_secrets(sor, org_id=org_id, user_id=user_id, only_active=str(params.get("all", "")) not in ("1", "true", "True")) return _fmt_meta_list(rows) async def h_save_secret(sor, params: Dict, ctx: Dict) -> str: org_id, user_id = _ctx_ids(ctx) if not user_id: return "FAIL: 当前会话没有用户身份,拒绝保存敏感信息" value = params.get("value") or params.get("secret") or "" name = params.get("name") or "" if not value: return "FAIL: 需要 value(敏感信息明文,只在本次调用中使用,落库前即加密)" name = normalize_name(name) if name else _suggest_name(value, params.get("label") or "") if not name: return "FAIL: name 不合法(须字母开头,只含字母/数字/下划线)" r = await save_secret(sor, name=name, value=value, org_id=org_id, user_id=user_id, label=params.get("label") or "", secret_type=params.get("secret_type") or "", source=params.get("source") or "agent", remark=params.get("remark") or "", who=user_id) if not r.get("ok"): return r.get("message", "FAIL: 保存失败") return (r.get("message", "") + "\n占位符:@@sec:" + r["name"] + "@@\n命令中引用:$" + ENV_PREFIX + r["name"]) async def h_use_secret(sor, params: Dict, ctx: Dict) -> str: """把选中的敏感信息插入会话(需求2)——返回占位符,绝不返回值。""" org_id, user_id = _ctx_ids(ctx) if not user_id: return "FAIL: 当前会话没有用户身份" name = normalize_name(params.get("name") or "") if not name: return "FAIL: 需要 name(用 list_secrets 查看可用名称)" # own_only=False(默认):读/用路径必须能看到机构共享凭据——那正是共享的意义。 # 与写路径(save/delete/set_status 用 own_only=True)刻意分离。 row = await get_secret_row(sor, name=name, org_id=org_id, user_id=user_id) if not row: rows = await list_secrets(sor, org_id=org_id, user_id=user_id) avail = ", ".join(str(x.get("name", "")) for x in rows[:20]) or "(空)" return "FAIL: 找不到敏感信息 " + name + "。可用:" + avail if row.get("status") != "active": return "FAIL: " + name + " 已停用,先用 set_secret_status 启用" return ("已插入会话。后续引用方式:\n" "- 文本/消息里用占位符:@@sec:" + name + "@@\n" "- shell 命令里用环境变量:$" + ENV_PREFIX + name + "\n" "(元数据:类型 " + str(row.get("secret_type", "")) + ",前缀 " + str(row.get("prefix_hint", "")) + ",长度 " + str(row.get("length_hint", 0)) + "。明文不会显示给你,也不会进入对话记录。)") async def h_delete_secret(sor, params: Dict, ctx: Dict) -> str: org_id, user_id = _ctx_ids(ctx) if not user_id: return "FAIL: 当前会话没有用户身份" r = await delete_secret(sor, name=normalize_name(params.get("name") or ""), secret_id=params.get("id") or "", org_id=org_id, user_id=user_id, who=user_id) return r.get("message", "FAIL") async def h_set_secret_status(sor, params: Dict, ctx: Dict) -> str: org_id, user_id = _ctx_ids(ctx) if not user_id: return "FAIL: 当前会话没有用户身份" r = await set_secret_status(sor, name=normalize_name(params.get("name") or ""), status=(params.get("status") or "").strip(), org_id=org_id, user_id=user_id, who=user_id) return r.get("message", "FAIL") async def h_detect_secret(sor, params: Dict, ctx: Dict) -> str: """只侦测不入库(用户想先看看会命中什么)。""" text = params.get("text") or "" if not text: return "FAIL: 需要 text" cands = detect_candidates(text) if not cands: return "未侦测到敏感信息(已知前缀 0 命中、关键词+熵门限 0 命中)。" lines = ["侦测到 " + str(len(cands)) + " 处疑似敏感信息:"] for c in cands: v = c["value"] lines.append("- " + v[:4] + "…(长度" + str(len(v)) + ") 建议名=" + c["name_suggest"] + " 类型=" + c["secret_type"] + " 置信度=" + c["confidence"] + " 来源=" + c["source"] + " 熵=" + str(c["entropy"])) lines.append("(只显示前4字符与元数据,未回显完整值。用 save_secret 可入库。)") return "\n".join(lines) # 工具定义(ToolDefinition 由调用方按 core 的类构造;这里给声明数据,避免 core 反向依赖) SECRET_TOOL_SPECS = [ { "name": "list_secrets", "description": "列出当前用户/机构可用的敏感信息(只返回名称/类型/前缀/长度等元数据,绝不返回明文值)。需要用凭据执行命令前先查这个。", "parameters": {"all": "传 1 含已停用条目(可选)"}, "category": "secret", "required": [], }, { "name": "save_secret", "description": "把一份敏感信息密文入库(apikey/token/密码/私钥等)。用户说「把 X 存为 Y」「保存这个 key」时调用。入库后返回占位符与环境变量名。", "parameters": {"name": "变量名(大写字母数字下划线,缺省自动派生)", "value": "明文值(仅本次调用使用,落库即加密)", "label": "显示名(可选)", "secret_type": "类型(可选,自动猜)", "remark": "备注(可选)"}, "category": "secret", "required": ["value"], }, { "name": "use_secret", "description": "从敏感信息表中选一个插入当前会话(返回占位符 @@sec:NAME@@ 与环境变量名,不返回明文)。用户说「用我的 github token」「插入那个 apikey」时调用。", "parameters": {"name": "变量名(用 list_secrets 查)"}, "category": "secret", "required": ["name"], }, { "name": "delete_secret", "description": "删除一条敏感信息(仅本人/本机构条目)。", "parameters": {"name": "变量名", "id": "记录ID(可选,与name二选一)"}, "category": "secret", "required": [], }, { "name": "set_secret_status", "description": "启用/停用一条敏感信息(停用后不再注入执行环境,但保留记录)。", "parameters": {"name": "变量名", "status": "active 或 disabled"}, "category": "secret", "required": ["name", "status"], }, { "name": "detect_secret", "description": "只侦测文本中的疑似敏感信息不入库(返回前4字符+类型+置信度)。用户问「这段有没有泄露风险」时调用。", "parameters": {"text": "待检测文本"}, "category": "secret", "required": ["text"], }, ] SECRET_HANDLERS = { "list_secrets": h_list_secrets, "save_secret": h_save_secret, "use_secret": h_use_secret, "delete_secret": h_delete_secret, "set_secret_status": h_set_secret_status, "detect_secret": h_detect_secret, } #: v1 角色 agent 的工具格式({"name","description","params","required"},与 AGENT_TOOLS 同构)。 #: v2 走 core 的 ToolDefinition(category="secret"),两套定义同源 SECRET_TOOL_SPECS, #: 避免描述漂移——改一处两边同步。 SECRET_TOOLS_V1 = [ {"name": s["name"], "description": s["description"], "params": dict(s.get("parameters") or {}), "required": list(s.get("required") or [])} for s in SECRET_TOOL_SPECS ] #: 角色 agent 默认不挂「写」工具(save/delete/set_status)——无人值守场景下 #: LLM 自主增删凭据风险高于收益,只给读与用(list/use/detect)。 #: 会话 agent(有真人 user_id)挂全套六件。 SECRET_TOOLS_V1_READONLY = [ t for t in SECRET_TOOLS_V1 if t["name"] in ("list_secrets", "use_secret", "detect_secret") ] #: system prompt 片段(会话 agent + 角色 agent 共用) SECRET_PROMPT_BLOCK = """ ## 敏感信息(凭据)使用铁律 平台有用户敏感信息库(apikey/token/密码/私钥),密文存储。**你自始至终看不到明文,这是设计如此。** - 需要凭据时先 `list_secrets` 查有哪些(只给元数据:名称/类型/前缀/长度)。 - 在 shell 命令里引用凭据,一律写环境变量 `$PIPELINE_SEC_<名称>`,平台会在执行瞬间注入真值。 例:`curl -H "Authorization: Bearer $PIPELINE_SEC_GITHUB_TOKEN" https://api.github.com/user` - **禁止**做这些事(会破坏保护): - 试图把凭据明文打印出来(`echo $PIPELINE_SEC_X`、`printenv PIPELINE_SEC_X`)——输出会回到对话与数据库,等于泄露; - 猜、编造、或要求用户提供明文值(用户给明文时平台会自动侦测入库,你只需继续用变量名); - 把凭据写进文件、commit 进 git、或放进 URL 查询参数(会落到对方访问日志)。 - 用户在消息里直接贴了明文(如「apikey:key-xxxx」)时,平台入站门禁已自动密文入库并把原文替换成占位符 `@@sec:<名称>@@`——你会在消息里看到占位符而不是明文,直接用对应环境变量即可,不需要再问用户要。 - 引用了不存在的名称时占位符会原样保留(不会被替换成空值),命令会失败并报错——这时用 `list_secrets` 核对名称。 """