feat(secret_vault): 用户敏感信息管理模块(2026-09-17)

敏感信息入库 + 会话占位符引用 + 入站自动侦测 + 执行边界注入,真值全程不进
模型上下文/不落库/不进命令字符串(根治"用户贴apikey→每轮上行供应商"泄露面)。

核心 secret_vault.py:
- AES 密文入库(appPublic.aes + UTF-8包装 + AES1$版本前缀)。注:ahserver 的
  password_encode 实测是 RC4 薄封装(globalEnv.py:61),非 AES,故直接用 aes 模块。
  aes.py 第42/43行密文侧 iso-8859-1 必须保留(实测200组密文utf-8解码100%失败),
  只在调用方对明文侧做 UTF-8→latin-1 字节保真包装(中文/emoji/私钥 9/9 往返一致)。
- 占位符 @@sec:NAME@@(避开 sqlor ${X}$ 与 ahserver ArgsConvert 同语法冲突,
  也避开裸 $VAR 被 bash 就地展开)。
- 侦测三通道:已知前缀16种 / 关键词邻域+熵≥3.5 / 不做裸串侦测(防吞哈希数据)。
  中文冒号「apikey:」支持;_JUNK_VALUE_RE 拦占位示例值;低熵弱口令不误吞。
- 执行边界:占位符→$PIPELINE_SEC_NAME,真值走 subprocess env=(不用 bwrap
  --setenv——值进argv同机任意用户ps可读;env=落/proc/pid/environ仅属主可读)。
- 越权防护:get_secret_row(own_only=) 读写范围分离 + _assert_own 代码级二次校验
  (防任意机构成员覆盖/删除机构共享凭据)。写操作显式 COMMIT(多worker)。
- 审计只记 name/type,禁记值。

三路接入:
- gateway._secret_inbound_gate: run_message Step 0.5(所有上下文解析之前),
  progress 事件回显动作(体感不变但告知已自动保存)。
- agent_loop_v2: _t_secret_tool 六工具 + _t_run_command 执行边界(前台/后台分支
  之前) + _t_process 出站擦洗 + prompt 铁律(仅有 user_id 时注入)。
- agent_loop(v1角色agent): 只读三件(list/use/detect,无人值守禁自主增删凭据);
  身份=项目真人owner(resolve_project_owner,已处理 agent.pm 自动立项反查);
  run_shell 执行边界 + 出站擦洗。
- _run_shell/bg_jobs.start_bg_job 加 secret_env 参数(无secret时env=None零改动)。

本地测试 124 断言全绿(含三个挂调实锤陷阱回归:子串碰撞/占位符别名劫持/
未知占位符吞空 + 跨文件工具名同步 + 越权防护)。表定义 m0037(pipeline-app)。
This commit is contained in:
ymq 2026-09-17 15:44:56 +08:00
parent bb6bdf40fa
commit 25c2452647
6 changed files with 1419 additions and 10 deletions

119
README.md
View File

@ -25,6 +25,7 @@
| pipeline_agent_instances | 角色 agent 实例注册(项目×角色→稳定实例 id;唯一写入方 agent_instance.py;m0002 建表未进 models,2026-09-10 归籍) |
| pipeline_agent_settings | 用户 agent 偏好(每用户一行 uk_user:默认模型/当前项目指针;写入方 agent_loop_v2、set_agent_model.dspy、workspace.py;2026-09-10 归籍) |
| pipeline_project_agents | 项目角色 agent 配置(驾驶舱 v1 start_agent 链路;v2 已走 pipeline_agent_instances,保留至 v1 链路删除决议;2026-09-10 归籍) |
| pipeline_user_secrets | 用户敏感信息库(2026-09-17,m0037 建表):AES 密文 + 指纹去重 + org/user 双维归属;写入方 secret_vault.py(agent 工具 + gateway 入站自动侦测);唯一索引 (org_id,user_id,name) |
## 安装
@ -51,6 +52,7 @@ cd pkgs/pipeline-service && pip install .
| `bg_jobs.py` | run_command 后台任务(状态文件化 .bg/,跨 worker 可见) |
| `subagents.py` | 后台并行子 agent 委派(.sub/,spawn/steer/stop/result) |
| `diagram_gate.py` | 文档配图形态硬门禁(2026-09-15):交付文档/标书章节中的图必须 invoke_model t2i/i2i 真图,确定性检测 mermaid/plantuml/ASCII 伪图;deliver 双路径拦截 + pipeline-bidding write/review_chapter 复用;豁免=文中如实标注「配图缺失:平台无可用文生图模型」 |
| `secret_vault.py` | 用户敏感信息库(2026-09-17):AES 密文入库 + 会话占位符引用 `@@sec:NAME@@` + 入站自动侦测入库;真值只在执行边界以子进程 env 注入,永不进模型上下文/落库/进命令字符串;gateway 入站门禁 + v1 角色 agent(项目 owner 身份)+ v2 会话 agent 三路接入 |
## 通用会话产线隔离(2026-09-05,7 层纵深防御)
@ -184,3 +186,120 @@ timeout → payload 无 _timeout → inference 按供应商端点配置(百炼
外层 wait_for(若有)≥ T+60。agent_loop:510→570→600 实证对齐。
验证特征串:inference 日志「预算=Ns」;llm_call_trace status_code=0 计数。
新增 LLM 调用点只需(可选)声明总预算,禁止自算客户端等待。
## 用户敏感信息库(secret_vault,2026-09-17)
**要解决的问题**:用户在会话里直接贴 apikey/token 时,明文会进模型上下文 → 随每轮 API
调用完整上行供应商 → 落 `pipeline_conversations` 永久留存 → session_search 捞回再上行。
(Hermes 侧同源分析实测:一个 5 轮任务的 key 上行 3~5 次,含重试可翻倍。)
**设计不变量(写死在代码,不靠 LLM 自觉)**
1. **真值只以子进程环境变量形式存在**,绝不拼进命令字符串。占位符 `@@sec:NAME@@`
在执行边界转成 `$PIPELINE_SEC_NAME`,值经 `subprocess env=` 注入。
→ 命令原文可安全落库/进上下文;模型抄回占位符是**预期行为**(不像脱敏成 `***`
会让模型把占位符抄进命令导致任务全断)。
2. **env 注入走 `env=`,不用 bwrap `--setenv`**:后者把值放进 bwrap 的 argv,同机任意
用户 `ps aux` / `/proc/<pid>/cmdline` 可读(world-readable);`env=` 落在
`/proc/<pid>/environ`,仅属主与 root 可读。bwrap 未用 `--clearenv`,默认继承自身环境,
所以 `env=` 的值能进沙箱内。
3. **未知占位符原样保留 + 拒绝执行**,绝不替换成空串。空串会静默改变命令语义
(实测 `rm -rf ${TARGET_DIR}$/` → `rm -rf /`)。
4. **读写权限范围分离**(`get_secret_row(own_only=)`):读/用路径含机构共享条目
(`user_id=''`,共享凭据就是给成员用的);写路径(save/delete/set_status)只匹配本人
条目 + `_assert_own` 代码级二次校验。否则任意机构成员可覆盖/删除全机构共用凭据。
5. **入站侦测只吃高置信度**:已知凭据前缀(16 种)或关键词邻域 + 熵 ≥3.5。
中低置信度只提示不自动入库——误报会把哈希/base64 数据块当凭据吞掉,静默毁数据,
比漏报更糟。**不做无上下文裸串侦测**(同理)。
**占位符语法为什么是 `@@sec:NAME@@`**
不能用 `${X}$`:sqlor 参数化(`sor.py:78` `sqlvp='${', sqlvs='}$'`)与 ahserver
`baseProcessor.py:193` 的 `ArgsConvert("${","}$")` 都用这个语法,复用会被误展开。
也不能用裸 `$VAR`:bash 会就地展开,绕过执行边界。
**加密:AES(appPublic.aes),不是 password_encode**
`ahserver.globalEnv.password_encode/decode`(:255-261)实测是 **RC4** 薄封装
(`:61 from appPublic.rc4 import password, unpassword`),换过去等于没换。
真正的 AES 在 `appPublic.aes`(AES-ECB + PKCS7 + base64),与 DB 连接密码同套。
⚠️ `aes.py` 内有**两处方向相反**的 iso-8859-1,只有一处需要包装:
| 位置 | 方向 | 结论 |
|---|---|---|
| `aes.py:42-43,46-47` | 密文 bytes → str | **必须** iso-8859-1。实测 200 组 AES 密文用 utf-8 解码失败 200 组(100%,首字节 0xef 即 invalid continuation byte)——密文是二进制,不符合 UTF-8 规范。历史正确设计,**不要动** |
| `aes.py:29,37` | 明文 str → bytes | iso-8859-1 表示不了非 ASCII。实测「密码是中文Abc123」「🔑key123」直接 UnicodeEncodeError |
解法在**调用方**包装(不改 aes.py——appPublic 是多宿主共享基础模块,改它影响 DB 密码
等全部调用方):`str → UTF-8 bytes → 按 latin-1 逐字节还原成等长 str 传入`。latin-1 是
字节保真映射(0x00-0xFF 一一对应),`aes.py:29` 再 `encode('iso-8859-1')` 即还原成原始
UTF-8 字节流 = 字节保真空操作。出库反向还原。包装后实测 9/9 往返一致(ASCII / GitHub
PAT / 中文 / emoji / 中英混合+符号 / 私钥含换行 / 512 长值 / `p@$$w0rd!#%^&*()` /
含 `@@sec:` 语法的值),密文零明文残留。
密文带版本前缀 `AES1$`:base64 密文无固定形态特征,无法像 RC4 的 `QUZVcX` 靠前缀嗅探
判断是否已加密;显式版本标记同时为算法迁移留路。`looks_encrypted()` 同时识别 `AES1$`
与历史 `QUZVcX`(幂等加密防多层叠加 + 向后兼容读取 llm 表等存量 RC4 密文)。
**三路接入**
| 路径 | 位置 | 身份来源 | 工具集 |
|---|---|---|---|
| 入站门禁 | `gateway.run_message` Step 0.5(所有上下文解析**之前**) | `get_user()` | 自动侦测→入库→替换,progress 事件回显动作 |
| v2 会话 agent | `agent_loop_v2._t_secret_tool` + `_t_run_command` + `_t_process` | `self.user_id`(作用域解析里被 generic 置空的那份**不能用**) | 全六件 |
| v1 角色 agent | `agent_loop._exec_agent_tool` + `run_shell` 分支 | 项目真人 owner(`rag_client.resolve_project_owner`,已处理 `created_by='agent.pm'` 自动立项反查) | 只读三件(list/use/detect)——无人值守下 LLM 自主增删凭据风险高于收益 |
入站门禁必须在 Step 0.5:净化后的 content 才是该进模型上下文与落库的版本,
明文一旦进 executor 就会每轮上行供应商。
出站净化两处:`_t_run_command`(前台)+ `_t_process`(包一层 `_t_process_raw`,
后台 output.log 可能回显凭据)。执行边界必须在前台/后台分支**之前**,否则后台命令不受保护。
**前端(AgentIO 钥匙图标)**
bricks 是多应用共用框架,**不硬编码任何宿主路径或占位符格式**(对齐 `model_dataurl`
既有设计)。宿主通过 opts 注入 `secret_dataurl` / `secret_format` / `secret_title` /
`secret_tip`;未传 `secret_dataurl` 时钥匙图标根本不渲染 → 其他应用零影响。
- `UiText.insertAtCursor(text)`:光标位置插入、有选区则替换选区。两个时序坑照抄
`handle_enter`/`handle_tab_indent` 既有处理:程序改 `dom_element.value` 不触发 input
事件(须手动同步 `this.value`);`focus()` 后浏览器可能重置 selectionStart
(用 `schedule_once` 0.5ms 后重设)。**不 dispatch('changed')**——UiText 也用于 Form,
多发一次会触发脏值/校验联动。
- 弹窗 API:`PopupWindow` 无 `close()` 也无 `'closed'` 事件,正确是 `dismiss()` +
`'dismissed'`(popup.js:475/481)。
- 样式走全局 css class(`.secret-picker-row` 等)——bricks 内联嵌套 style 对象无效。
- 6 处 AgentIO 入口全部注入:`pipeline-core/wwwroot/agent/index.ui`(静态 + 动态建 tab
脚本各一处)、`agent_generic/index.ui`、`mobile_agent.ui`(2 处)、
`pipeline-sdlc/wwwroot/index.ui`、`index_cockpit.ui`。
- 后端 `pipeline-core/wwwroot/api/secret_options.dspy`:身份取自 `get_user()`/`get_userorgid()`,
**不接受前端传 user_id/org_id**(否则改参数即可枚举他人凭据名称);双保险剔除
`encrypted_value`/`fingerprint`;未登录返 `success:false`(区分「没登录」与「没数据」)。
RBAC 已被 `rp.json` 的 `/pipeline_core/**`(logined) 覆盖,无需额外注册。
**性能(实测,本地 20KB 文本)**
`tokenize` 0.003ms / `detokenize` 0.016ms / 4KB 侦测 0.64ms / 用户消息侦测 0.034ms。
**三个挂调实锤的实现陷阱(勿回退,有回归测试)**
| 陷阱 | 症状 | 修复 |
|---|---|---|
| 子串碰撞 | 短 key `key-Ab3x` 与长 key 共存时按插入序替换 → 长 key 被吃掉前缀、尾部明文裸露进上下文 | `_sorted_secrets` 按值长度**降序**替换 |
| 占位符别名劫持 | secret 的值本身长得像占位符 → 二次 token 化改写既有占位符 | 入库拒绝值内含 `@@sec:`(`_PLACEHOLDER_IN_VALUE`)+ tokenize 幂等 |
| 未知占位符吞空 | `ArgsConvert(default='')` 把缺失变量替换成空串 → 命令语义反转 | `detokenize` 原样保留 + 回报未知名单;执行边界拒绝执行 |
另两个自产 bug:`_PLACEHOLDER_RE` 用 `{1,62}`(最少 2 字符)与 `_NAME_RE` 长度下界不一致
→ 单字符名存得进库但识别不出(未知占位符漏报);`guess_type` 名称关键词优先 →
`openai_style_key` 里的 `key` 把精确类型降级成 `api_key`。均已修 + 加同步断言。
**测试**:`/tmp/test_secret_vault.py` 124 断言全绿(11 组:AES 加解密/子串碰撞/别名劫持/
未知占位符/侦测/名称归一/端到端/性能/DB 层/跨文件工具名同步/越权防护)。
桩 `CapturingSor` **必须真实模拟 WHERE 过滤**——第一版对任何 SELECT 都返回全部 rows,
无视 SQL 条件,导致「零写操作」断言在过滤正确时反而 FAIL(假阳性)。
测试桩失真 = 测试证明不了任何事。
**部署**:`dist/` 在 bricks 的 .gitignore 里(构建产物不入库)→ 部署必须在测试机
`cd pkgs/bricks && bash build.sh` 重建,不能靠 git pull(同「bricks 追平必须连 dist 重建」)。
建表走 m0037 + `create_tables.py`(自动扫描 `pkgs/*/models`,索引按列集合幂等判重)。

View File

@ -41,6 +41,14 @@ ROLE_ALIASES = {
TASK_REVIEW = 'review'
TASK_APPROVED = 'approved'
# 用户敏感信息库工具名(2026-09-17)。必须与 secret_vault.SECRET_HANDLERS 的键一致
# (有测试 test_secret_vault.py 的 J 段断言同步,改一处必须改两处)。
# 分发在 _exec_agent_tool;注册在 role_agent_run(只挂只读三件 SECRET_TOOLS_V1_READONLY)。
_SECRET_TOOL_NAMES_V1 = frozenset((
'list_secrets', 'save_secret', 'use_secret',
'delete_secret', 'set_secret_status', 'detect_secret',
))
# 项目过程仓库名(repos/ 下):阶段文档、QC 审计文档、项目管理文档放这里;应用仓库/模块仓库各自独立。
# 实际仓库名 = {项目名}_pc(见 project-directory-spec 规范),此常量仅为查不到项目名时的回退默认值。
PROJECT_REPO_NAME = 'project'
@ -364,18 +372,29 @@ def _build_agent_bwrap_cmd(bwrap: str, cwd: str, writable_root: str, command: st
return parts
async def _run_shell(command, workdir, timeout=120, strict=False):
async def _run_shell(command, workdir, timeout=120, strict=False, secret_env=None):
"""安全执行 shell 命令(优先 bwrap 沙箱)。返回 {"rc","stdout","stderr","sandbox"}。
strict=True(通用会话档):不挂平台目录只读(/d/pipeline、/d/doit 完全不可见),
可写根收窄到 cwd 本身(配合 _sandbox_writable_root 的 _general/{uid} 用户级)。
strict=False(产线档,默认,行为不变):平台目录只读 + 机构级可写。
secret_env(2026-09-17 敏感信息库):{VAR: 明文} 注入子进程环境变量。
走 subprocess env= 参数而非 bwrap --setenv——后者把值放进 bwrap 的 argv,
同机任意用户 `ps`/`/proc/<pid>/cmdline` 可读(world-readable);env= 落在
/proc/<pid>/environ,仅属主与 root 可读。bwrap 未用 --clearenv,默认继承
自身环境,所以 env= 的值能进沙箱内。命令字符串本身不含真值(可安全落库)。
"""
cwd = os.path.abspath(workdir) if workdir else WORKSPACE_BASE
if not await _is_safe_workdir_async(cwd):
return {"rc": -1, "stdout": "", "stderr": f"安全限制:目录 {cwd} 不在允许范围", "sandbox": False}
if not os.path.isdir(cwd):
return {"rc": -1, "stdout": "", "stderr": f"目录不存在: {cwd}", "sandbox": False}
# 环境变量注入:基于当前进程环境叠加(只加被引用到的 secret,最小暴露面)
_env = None
if secret_env:
_env = dict(os.environ)
_env.update({str(k): str(v) for k, v in secret_env.items()})
bwrap = _find_bwrap()
try:
if bwrap:
@ -384,12 +403,13 @@ async def _run_shell(command, workdir, timeout=120, strict=False):
include_platform_ro=not strict)
# bwrap 用列表直接执行(非 shell 拼接);命令本身仍由沙箱内 bash -c 解释
proc = await asyncio.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
env=_env)
else:
# 降级:无 bwrap,保持原有目录隔离(弱隔离),标记 sandbox: False
proc = await asyncio.create_subprocess_shell(
command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
cwd=cwd, executable='/bin/bash')
cwd=cwd, executable='/bin/bash', env=_env)
try:
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
except asyncio.TimeoutError:
@ -2181,6 +2201,37 @@ async def _exec_agent_tool(tool, params, workspace_dir, ctx=None):
tool, p, org_id=str((ctx or {}).get('org_id', '') or '0'),
user_id=str((ctx or {}).get('user_id', '') or ''),
project_id=str((ctx or {}).get('project_id', '') or ''))
# 用户敏感信息库(2026-09-17):角色 agent 无人值守,身份取 ctx.user_id
# (= role_agent_run 解析的项目真人 owner)。身份为空时 handler 自拒——
# 不给无主凭据开口子。角色 agent 只挂只读三件(list/use/detect,见
# role_agent_run 的 SECRET_TOOLS_V1_READONLY),但分发层对全部六件放行,
# 写操作靠"工具未注册进 schema"挡住(LLM 看不到就不会调),双保险。
if tool in _SECRET_TOOL_NAMES_V1:
from . import secret_vault
_suid = str((ctx or {}).get('user_id', '') or '')
if not _suid:
return ("FAIL: 当前任务无法解析项目 owner,不提供敏感信息能力"
"(无人值守且无归属人,防凭据错用)。")
_sh = secret_vault.SECRET_HANDLERS.get(tool)
if not _sh:
return f"FAIL: 未知敏感信息工具 {tool}"
try:
from sqlor.dbpools import DBPools
db = DBPools()
async with db.sqlorContext("pipeline") as _ssor:
_r = await _sh(_ssor, p, {
"user_id": _suid,
"org_id": str((ctx or {}).get('org_id', '') or ''),
"project_id": str((ctx or {}).get('project_id', '') or ''),
})
try:
await _ssor.sqlExe("COMMIT", {})
except Exception:
pass
return _r
except Exception as e:
logger.warning("secret tool %s failed: %s", tool, str(e)[:160])
return f"ERROR: {str(e)[:300]}"
if tool == 'read_file':
path = p.get('path', '')
if not path: return 'FAIL: 需要文件路径'
@ -2229,8 +2280,40 @@ async def _exec_agent_tool(tool, params, workspace_dir, ctx=None):
_org = (ctx or {}).get('org_id', '') or ''
if _who in ('agent.deploy_test', 'agent.deploy_prod') and _org:
cmd = _inject_org_ssh_key(cmd, _org)
r = await _run_shell(cmd, workspace_dir, timeout=120)
return f"rc={r['rc']}\nSTDOUT:\n{r['stdout'][:2000]}\nSTDERR:\n{r['stderr'][:1000]}"
# 敏感信息执行边界(2026-09-17):占位符 @@sec:NAME@@ → $PIPELINE_SEC_NAME,
# 真值经 env 注入子进程,不进命令字符串(命令可安全落库/进模型上下文)。
# 未知占位符 → 拒绝执行(绝不替换成空串,空串会静默改变命令语义:
# 实测 `rm -rf ${TARGET_DIR}$/` → `rm -rf /`)。
_secret_env = None
_suid = str((ctx or {}).get('user_id', '') or '')
if _suid:
try:
from . import secret_vault
from sqlor.dbpools import DBPools
_db = DBPools()
async with _db.sqlorContext("pipeline") as _ssor:
cmd, _secret_env, _unknown = await secret_vault.prepare_command(
_ssor, cmd, org_id=_org, user_id=_suid)
if _unknown:
return ("FAIL: 命令引用了不存在的敏感信息 " + ", ".join(_unknown) +
"(已拒绝执行,防止空值改变命令语义)。用 list_secrets 核对名称。")
except Exception as e:
logger.warning("run_shell secret prepare failed: %s", str(e)[:160])
r = await _run_shell(cmd, workspace_dir, timeout=120, secret_env=_secret_env)
_out = f"rc={r['rc']}\nSTDOUT:\n{r['stdout'][:2000]}\nSTDERR:\n{r['stderr'][:1000]}"
# 出站净化:命令输出可能回显凭据(curl -v、报错信息常见),擦洗后再回模型
if _suid:
try:
from . import secret_vault
from sqlor.dbpools import DBPools
async with DBPools().sqlorContext("pipeline") as _ssor:
_out, _scrub = await secret_vault.sanitize_tool_output(
_ssor, _out, org_id=_org, user_id=_suid)
if _scrub:
_out += "\n(输出中的敏感信息已替换为占位符:" + ", ".join(_scrub) + ")"
except Exception as e:
logger.warning("run_shell output sanitize failed: %s", str(e)[:160])
return _out
elif tool == 'git_clone':
url = p.get('repo_url', '')
if not url: return 'FAIL: 需要仓库URL'
@ -2931,7 +3014,10 @@ async def role_agent_run(project_id, role, agent_id=None, model_name=None):
# (本机构+平台owner机构,生成类任务按 task 自动选型)。唯一实现在
# platform_model_tools,分发在 _exec_agent_tool。
from .platform_model_tools import PLATFORM_MODEL_TOOLS_V1
all_tools = AGENT_TOOLS + capability_tools + PLATFORM_MODEL_TOOLS_V1
# 敏感信息库(2026-09-17):角色 agent 只挂只读三件(list/use/detect)——
# 无人值守场景下 LLM 自主增删凭据风险高于收益;写操作留给有真人身份的会话 agent。
from .secret_vault import SECRET_TOOLS_V1_READONLY
all_tools = AGENT_TOOLS + capability_tools + PLATFORM_MODEL_TOOLS_V1 + SECRET_TOOLS_V1_READONLY
# ── deliverable_type 守卫(2026-09-14 pbls):角色声明了合法交付类型时,
# ① 工具描述动态改写为只列合法值(源头防照抄示例)② deliver 拦截校验(硬门禁)。
@ -2965,6 +3051,17 @@ async def role_agent_run(project_id, role, agent_id=None, model_name=None):
_iter = await get_current_iteration(sor, project_id)
except Exception:
_iter = None
# 敏感信息库归属:解析项目真人 owner(角色 agent 无自身 user_id)。
# 复用 rag_client.resolve_project_owner(已处理 created_by='agent.pm' 的自动立项
# 场景——反查创建会话的真人)。解析失败留空串,handler 侧拒绝,不硬塞。
_secret_owner = ""
try:
from .rag_client import resolve_project_owner
_secret_owner, _owner_err = await resolve_project_owner(project_id)
if not _secret_owner and _owner_err:
logger.info("role_agent secret owner unresolved: %s", _owner_err)
except Exception as e:
logger.warning("resolve secret owner failed: %s", str(e)[:120])
capability_ctx = {
"project_id": project_id,
"iteration_id": _iter.get('id', '') if _iter else '',
@ -2972,6 +3069,10 @@ async def role_agent_run(project_id, role, agent_id=None, model_name=None):
"agent_id": agent_id,
"task_id": task_id,
"org_id": org_id or '0',
# 敏感信息库归属(2026-09-17):角色 agent 无人值守、无自身 user_id,
# 凭据身份必须取项目真人 owner——否则要么拿不到用户凭据,要么错用他人凭据。
# 解析失败留空串:secret_vault 的 handler 会拒绝(不给无主凭据开口子)。
"user_id": _secret_owner,
}
system = (AGENT_SYSTEM_PROMPT
@ -2983,6 +3084,14 @@ async def role_agent_run(project_id, role, agent_id=None, model_name=None):
.replace('__ROLE_SPECIFIC__', role_specific)
.replace('__ROLE_SKILLS__', role_skills)
.replace('__TOOLS__', tools_text))
# 敏感信息使用铁律(2026-09-17):仅在解析到项目 owner 时注入——
# 无归属人时 secret 工具会自拒,也就不给凭据使用引导(避免 LLM 反复试错烧轮次)。
if _secret_owner:
try:
from .secret_vault import SECRET_PROMPT_BLOCK
system += SECRET_PROMPT_BLOCK
except Exception as e:
logger.warning("inject secret prompt block (role agent) failed: %s", str(e)[:120])
msgs = [{"role": "system", "content": system}]
msgs.append({"role": "user", "content": f"执行任务:{title}\n参数:{params_str}"})

View File

@ -61,6 +61,14 @@ _PROJECT_TOOL_NAMES = {
# 名单保留机制本身,未来若有无法沙箱化的工具仍可加入。
_GENERIC_DENIED_TOOLS = set()
# 用户敏感信息库工具名(2026-09-17):与 pipeline_core.agent_config.GENERAL_TOOLS 里
# category="secret" 的工具一一对应,与 secret_vault.SECRET_HANDLERS 的键一致。
# 三处同步:工具定义(core) + handlers 映射(本文件 _dispatch_sdlc_tool) + handler 实现(secret_vault)。
_SECRET_TOOL_NAMES = (
"list_secrets", "save_secret", "use_secret",
"delete_secret", "set_secret_status", "detect_secret",
)
# ── 默认工具定义(在 pipeline-core 未加载时使用)──
@ -855,6 +863,15 @@ class AgentExecutor:
prompt = prompt.replace("{project_name}", proj_name)
prompt = prompt.replace("{tools_description}", tools_text)
# 敏感信息使用铁律(2026-09-17):仅在有真实用户身份时注入——
# 无人值守/内部 agent 不挂 secret 工具,也就不给凭据使用引导。
if self.user_id:
try:
from .secret_vault import SECRET_PROMPT_BLOCK
prompt += SECRET_PROMPT_BLOCK
except Exception as e:
logger.warning(f"inject secret prompt block failed: {e}")
return prompt
# ═══════════════════════════════════════════════════════
@ -1221,6 +1238,11 @@ class AgentExecutor:
"fetch_url": self._t_fetch_url,
# ── 项目数据只读查询(2026-09-08,白名单+强制项目过滤在 db_query)──
"query_project_data": self._t_query_project_data,
# ── 用户敏感信息库(2026-09-17):签名适配 (sor,params,ctx) ← (sor,p,pid) ──
# ctx 用 _build_ctx()(含真实 self.user_id/self.org_id)——secret 归属必须
# 是真实身份,不能用作用域解析里被 generic 置空的那份。
**{n: (lambda _n: (lambda s, _p, _pid: self._t_secret_tool(s, _n, _p)))(n)
for n in _SECRET_TOOL_NAMES},
}
handler = handlers.get(tool_name)
@ -1228,6 +1250,34 @@ class AgentExecutor:
return await handler(sor, p, pid)
return None
async def _t_secret_tool(self, sor, tool_name: str, params: dict) -> str:
"""敏感信息工具统一入口(2026-09-17)。
身份强制来自会话真实上下文(self.user_id / self.org_id),忽略 LLM 传的任何
归属参数——防越权读写他人 secret。无 user_id(无人值守/内部 agent)一律拒绝。
"""
from . import secret_vault
if not self.user_id:
return "FAIL: 当前会话没有用户身份,不提供敏感信息能力(防无人值守场景泄露凭据)"
h = secret_vault.SECRET_HANDLERS.get(tool_name)
if not h:
return f"FAIL: 未知敏感信息工具 {tool_name}"
try:
_r = await h(sor, params or {}, {"user_id": self.user_id,
"org_id": self.org_id or "",
"project_id": self.project_id})
# 写操作必须显式 COMMIT(多 worker 架构:不提交则其他进程读不到刚存的凭据)。
# 与本文件其他写库工具(_persist_project 等 6 处)同款做法;只读工具多跑一次
# COMMIT 无害(sqlor 事务幂等),省去按工具名区分读写。
try:
await sor.sqlExe("COMMIT", {})
except Exception as _ce:
logger.warning(f"secret tool {tool_name} commit failed: {_ce}")
return _r
except Exception as e:
logger.error(f"secret tool {tool_name} error: {e}")
return f"ERROR: {str(e)[:300]}"
# ── 工具实现 ──
async def _persist_project(self, sor, pid):
@ -1544,6 +1594,21 @@ class AgentExecutor:
return ("FAIL: 通用会话的命令执行需要 bwrap 沙箱(当前服务器不可用),已拒绝执行。"
"文件类操作请改用 read_file/write_file/list_files/search_files。")
# 敏感信息执行边界(2026-09-17):占位符 @@sec:NAME@@ → $PIPELINE_SEC_NAME,
# 真值经 env 注入子进程,**不进命令字符串**(命令可安全落库/进上下文)。
# 未知占位符原样保留(绝不替换成空串——空串会静默改变命令语义)。
# 必须在前台/后台分支**之前**处理,两条执行路径才都受保护。
secret_env = None
try:
from . import secret_vault
cmd, secret_env, unknown = await secret_vault.prepare_command(
sor, cmd, org_id=self.org_id or "", user_id=self.user_id or "")
if unknown:
return ("FAIL: 命令引用了不存在的敏感信息 " + ", ".join(unknown) +
"(已拒绝执行,防止空值改变命令语义)。用 list_secrets 核对名称。")
except Exception as e:
logger.warning(f"secret prepare_command failed (run without secrets): {e}")
# 后台执行(2026-09-10,对齐 Hermes terminal background):
# 状态文件化到 workspace/.bg/,跨 worker 进程可 poll;
# 沙箱档位与前台完全一致(generic 强制 strict,无 bwrap 上面已拒)。
@ -1552,7 +1617,8 @@ class AgentExecutor:
from . import bg_jobs
try:
job_id = await bg_jobs.start_bg_job(
cmd, self.workspace_dir, strict=self.generic)
cmd, self.workspace_dir, strict=self.generic,
secret_env=secret_env)
except bg_jobs.BgJobError as e:
return f"FAIL: {e}"
return (f"OK: 后台任务已启动 job_id={job_id}。"
@ -1563,17 +1629,46 @@ class AgentExecutor:
except (TypeError, ValueError):
timeout = 60
timeout = max(5, min(timeout, 300))
r = await _run_shell(cmd, self.workspace_dir, timeout=timeout, strict=self.generic)
r = await _run_shell(cmd, self.workspace_dir, timeout=timeout,
strict=self.generic, secret_env=secret_env)
out = f"rc={r['rc']}\n{r['stdout'][:2000]}"
if r.get('stderr'):
out += f"\nSTDERR: {r['stderr'][:500]}"
if self.generic and not r.get('sandbox'):
out += "\n(注意:本次未经过沙箱)"
# 出站净化(需求2的「执行返回当输入处理」):工具输出里若回显了
# 已入库凭据明文(报错信息常见),擦洗成占位符再回填模型上下文。
try:
from . import secret_vault
out, scrubbed = await secret_vault.sanitize_tool_output(
sor, out, org_id=self.org_id or "", user_id=self.user_id or "")
if scrubbed:
out += "\n(输出中出现的敏感信息已替换为占位符:" + ", ".join(scrubbed) + ")"
except Exception as e:
logger.warning(f"sanitize_tool_output failed: {e}")
return out
except Exception as e:
return f"ERROR: {str(e)[:300]}"
async def _t_process(self, sor, p, pid):
"""后台任务管理入口:先执行,再对输出做出站净化(2026-09-17)。
后台命令可能把凭据回显进 output.log(curl -v、报错信息常见),
读回模型上下文前擦洗成占位符——与前台 run_command 同一条防线。
"""
out = await self._t_process_raw(sor, p, pid)
try:
from . import secret_vault
out, scrubbed = await secret_vault.sanitize_tool_output(
sor, out, org_id=self.org_id or "", user_id=self.user_id or "")
if scrubbed:
out += "\n(输出中出现的敏感信息已替换为占位符:" + ", ".join(scrubbed) + ")"
except Exception as e:
logger.warning(f"sanitize process output failed: {e}")
return out
async def _t_process_raw(self, sor, p, pid):
"""后台任务管理(poll/log/wait/kill,配套 run_command background=true)。
隔离:只解析 self.workspace_dir 下的 .bg/——通用会话 workspace 是

View File

@ -27,6 +27,7 @@ import os
import re
import signal
import time
from typing import Optional
logger = logging.getLogger("pipeline.bg_jobs")
@ -85,9 +86,28 @@ def _pid_alive(pid: int) -> bool:
return False
def _bg_env(secret_env: Optional[dict]) -> Optional[dict]:
"""构造子进程环境:无 secret 时返回 None(继承默认行为,零改动风险)。
有 secret 时基于 os.environ 叠加,只加被引用到的那几条(最小暴露面)。
"""
if not secret_env:
return None
env = dict(os.environ)
env.update({str(k): str(v) for k, v in secret_env.items()})
return env
async def start_bg_job(command: str, workspace_dir: str, strict: bool = False,
timeout: int = MAX_BG_SECONDS) -> str:
"""启动后台命令,返回 job_id。沙箱/目录安全校验与前台 _run_shell 完全一致。"""
timeout: int = MAX_BG_SECONDS,
secret_env: Optional[dict] = None) -> str:
"""启动后台命令,返回 job_id。沙箱/目录安全校验与前台 _run_shell 完全一致。
secret_env(2026-09-17):{VAR: 明文} 注入子进程环境(同 _run_shell 的口径:
走 env= 不走 bwrap --setenv,避免值出现在 argv 被同机用户 ps 读到)。
⚠️ meta.json 存的是 command 原文(不含真值),output.log 是命令输出——
输出可能回显凭据,读取时经 sanitize_tool_output 擦洗(process 工具侧)。
"""
from .agent_loop import (_find_bwrap, _build_agent_bwrap_cmd,
_sandbox_writable_root, _is_safe_workdir_async)
@ -124,6 +144,7 @@ async def start_bg_job(command: str, workspace_dir: str, strict: bool = False,
*cmd, stdout=log_f, stderr=asyncio.subprocess.STDOUT,
cwd=None if bwrap else cwd,
start_new_session=True, # 独立进程组,kill 时整组终止
env=_bg_env(secret_env),
)
finally:
log_f.close()

View File

@ -160,6 +160,18 @@ class Gateway:
}, ensure_ascii=False) + "\n"
return
# 0.5 用户敏感信息入站门禁(2026-09-17):自动侦测明文凭据 → 密文入库 →
# 原文替换为占位符。放在所有上下文解析之前,因为**净化后的 content 才是
# 该进模型上下文与落库的版本**(明文一旦进 executor 就会每轮上行供应商)。
# 失败绝不阻断会话(降级为原文继续,安全增强不牺牲可用性)。
try:
content, secret_notes = await self._secret_inbound_gate(user_id, content)
except Exception as e:
logger.warning(f"secret inbound gate failed (continue with raw content): {e}")
content, secret_notes = content, []
for note in secret_notes:
yield json.dumps({"type": "progress", "message": note + "\n"}, ensure_ascii=False) + "\n"
# 1. 解析项目上下文(纯通用模式跳过,不挂任何产线插件)
if generic:
ctx = {"pid": "", "pipeline_id": "", "name": "", "project_model_name": ""}
@ -231,6 +243,42 @@ class Gateway:
image_parts=image_parts or None):
yield chunk
async def _secret_inbound_gate(self, user_id: str, content: str):
"""用户敏感信息入站门禁:侦测明文凭据 → 密文入库 → 替换为占位符。
返回 (净化后的 content, 动作说明列表)。动作说明以 progress 事件回显给用户
——「体感不变但安全增强」:用户照常打字,只多看到一条「已自动保存」的提示。
安全边界:
- 无 user_id(无人值守/内部调用)→ 不做任何处理,原文返回(不给无主凭据建库)。
- 只处理**高置信度**候选(已知凭据前缀,或关键词+熵≥3.5);中低置信度不自动入库,
避免把哈希/base64 数据块当凭据吞掉(误报会静默毁数据,比漏报更糟)。
- 任何异常都降级为原文继续,绝不阻断会话。
"""
if not user_id or not content:
return content, []
from . import secret_vault
from sqlor.dbpools import DBPools
db = DBPools()
async with db.sqlorContext("pipeline") as sor:
org_id = ""
try:
recs = await sor.sqlExe(
"SELECT orgid FROM users WHERE id=${u}$ LIMIT 1", {"u": user_id})
if recs:
org_id = getattr(recs[0], "orgid", "") or ""
except Exception as e:
logger.warning(f"secret gate: resolve org_id failed: {e}")
r = await secret_vault.scan_and_capture(
sor, content, org_id=org_id, user_id=user_id,
who=user_id, auto_store=True)
try:
await sor.sqlExe("COMMIT", {})
except Exception:
pass
return r.get("text") or content, list(r.get("actions") or [])
async def _resolve_and_persist_model(self, user_id: str, project_id: str,
model_id: str) -> str:
"""校验前端选中的模型并持久化到项目。返回 llm.name(空 = 无效/未持久化,调用方走回退链)。

File diff suppressed because it is too large Load Diff