安全模型
Familiars 的安全架构实现细节。涵盖风险分级、审批闸门、敏感信息检测管线、加密原语、设备认证、Bridge Token 鉴权、沙箱权限。 来源:
src/tools/security.py、src/tools/sensitive_detector.py、src/base/crypto.py、src/base/auth/、src/base/bridge_token.py、src/tools/turn_secrets.py、SECURITY.md。
1 · 风险分级体系
1.1 四级风险桶
src/tools/security.py 定义四个风险等级,每个等级映射一个整数:
| 等级 | 数值 | 语义 | 典型工具 |
|---|---|---|---|
low | 0 | 纯查询,无副作用 | ls、get_file_info、read_text_file、find_files、grep_files、list_archive、list_my_processes、screen_size、clipboard_get_type、network、host_status、wait、*_status/*_list |
medium | 1 | 有副作用但可逆 | write、edit、compress、decompress、screenshot、monitor_start、browser_open、web_search、create_schedule、fetch_email_*、clipboard_paste_image、open_with_default_app、run_ffmpeg、run_pandoc、http_request、所有 generate_pdf/word/ppt/excel、所有 word_*/ppt_*/excel_* 细粒度构建器 |
high | 2 | 不可逆或高危 | delete_file、execute_shell_script、execute_python_file、execute_node_file、run_pip、run_npm、run_git、open_app、kill_process、download、web_fetch、web_dive、move_or_rename、sqlite_query、ssh_exec、scp_upload、social_send、social_connect/disconnect、send_email、configure_email、generate_image/video、shut_down、lock_screen、operate、所有 mouse_*/keyboard_*(除 mouse_position)、所有 browser_click/type/eval_js/close_tab、clipboard_copy/paste/clear/copy_image/copy_files、start_screen_record、sign_pdf、hang_up、set_audio_routing、run_call_conversation |
super | 99 | 不可逆且对人影响——无论用户阈值如何都强制弹审批 | dial_number(真实电话呼叫) |
super 等级的设计:故意远离 high(数值 99 vs 2),使得用户阈值(最高只能升到 high=2)永远无法覆盖。dial_number 每次呼叫都弹审批——即便 intense_mode=True、FAMILIARS_APPROVAL_DISABLED=1、本轮白名单——全部失效。
1.2 TOOL_RISK 字典
TOOL_RISK: dict[str, str] 是单一真相源,约 150+ 工具方法名 → 风险等级。未列出的工具默认为 low。wrap_class_methods(cls) 在工具类加载时遍历类方法,自动给每个在 TOOL_RISK 中有名字的方法包上 @requires_approval 装饰器。
1.3 基于评分的动态风险升级
静态分级之外,每次工具调用都经过 _compute_effective_risk(tool_name, base_risk, kwargs) 做动态升级:
- 取工具的 base score(来自
TOOL_BASE_SCORE字典;未列出的用_score_from_level(base_risk)转为{low:10, medium:40, high:70, super:95}) - 对
kwargs里所有字符串值(递归至深度 4)跑一组轻量正则(_ARG_SCAN_PATTERNS,延迟编译并缓存),命中则累加 bonus score - 总分映射:
[0,30)=low、[30,60)=medium、[60,90)=high、90+=super
只升级、不降级。例如 write(path, content) 的静态 base=45(medium);若 content 含银行账号(cn-bank-card bonus=50),总分=95 → 升级为 super。
正则扫描规则及 bonus:
| 规则 | 正则 | bonus |
|---|---|---|
aws-access-key | AKIA[0-9A-Z]{16} | 50 |
github-pat | ghp_[A-Za-z0-9]{36} | 50 |
openai-key | sk-(proj-)?[A-Za-z0-9_-]{20,} | 50 |
anthropic-key | sk-ant-[A-Za-z0-9_-]{20,} | 50 |
google-api-key | AIza[0-9A-Za-z_-]{35} | 50 |
private-key-pem | BEGIN [A-Z ]*PRIVATE KEY | 50 |
jwt | eyJ...{10,}\..{10,}\..{10,} | 40 |
password-assign | (password|pwd|secret)[:=]...{6,} | 40 |
cn-mobile | 1[3-9]\d{9} | 30 |
cn-id-card | \d{17}[\dXx] | 45 |
cn-bank-card | \d{16,19} | 50 |
enc-token | ENC:: | 25 |
2 · 审批闸门机制
2.1 装饰器 @requires_approval
requires_approval(level=None) 是装饰器工厂。包装后的函数执行流程:
工具调用
│
├─ risk == "super" → 直接走 _ask_user("super") → 拒绝则 raise ToolDenied
│
├─ demo gate 已装(匿名 demo bridge) → _demo_gate() 返回合成响应或 fall through
│
├─ 全局禁用 / intense_mode / turbo_mode / operate_scope → 直接执行
│
├─ skill-scope 已授权 → 直接执行
│
├─ 本轮白名单("本轮总是允许") → 直接执行
│
└─ 正常路径:
effective_risk = _compute_effective_risk(...)
if effective_risk < user_threshold → 执行
else → _ask_user(effective_risk) → 批准则执行 / 拒绝则 raise ToolDenied2.2 用户阈值 security_level
UserConfig.security_level(即 DialogConfig.security_level,默认 "medium")决定阈值:
| 用户选择 | 阈值 | 含义 |
|---|---|---|
low | 0 | 所有工具(low+medium+high+super)都弹审批 |
medium(默认) | 1 | medium+high+super 弹审批,low 直接执行 |
high | 2 | 只有 high+super 弹审批(最宽松) |
_user_threshold() 内部用 min(threshold, _LEVELS["high"]) 确保阈值永远不超过 high——super 是框架保留的,无法被任何用户设置覆盖。
2.3 审批请求通道
_ask_user() 通过 CommClient.request_approval() 向进程内 CommHub 投递 ApprovalRequest,然后无限等待用户响应(timeout=None)。早期的 FAMILIARS_APPROVAL_TIMEOUT 已移除——超时不再默认拒绝(避免 ~10h word_new 卡死事件重演)。
_resolve_actor_for_approval() 有三级回退找 agent/dialogue id:
- security 模块的 ContextVar
_current_agent/_current_dialogue - observability 模块的
agent_id_var/dialogue_id_var - 广播(agent_id=0)+ 日志 WARNING + 可选堆栈(
FAMILIARS_DIAG_ACTOR_DRIFT默认开启)
2.4 环境变量开关
| 环境变量 | 效果 |
|---|---|
FAMILIARS_APPROVAL_DISABLED=1 | 全局短路审批门——所有工具直接执行(仅开发/批量用) |
FAMILIARS_DIAG_ACTOR_DRIFT=0 | 关闭 misrouted 审批的堆栈追踪(生产降噪) |
2.5 intense_mode / turbo_mode
intense_mode:全局 / 单 agent / WeChat/QQ trigger 临时都可设为 True → 所有工具直接执行(包括 high),super仍然弹。由bind_actor()在每个 turn 开始时从config.jsonoverlay 快照到 ContextVar_current_intense。turbo_mode:跳过审批但保留 plan 验证和ask_user_questions。与intense_mode互斥。operate_scope():operate()工具内部用 context manager 设_in_operate=True,审批 operate 本身即授权其内部所有子工具调用。
2.6 skill-scope 授权
authorize_skill(agent_id, skill_id) 记录用户一次性批准。之后该 skill 的所有步骤(通过 _current_skill_id ContextVar 识别)都绕过审批。ensure_skill_approved() 在 skill 启动时调用一次,展示步骤列表 + 聚合风险(max(step.tool_binding.risk))。revoke_skill_authorization() 在设置 UI 撤销授权或 skill 被删除时调用。
2.7 本轮白名单
allow_tool_for_turn(agent_id, tool_name) 在用户点击"本轮总是允许"时调用。bind_actor() 在每个 turn 开始时清空该 agent 的白名单(_always_allow.pop(agent_id)),确保白名单不跨 turn 残留。
2.8 ToolDenied 异常
ToolDenied(PermissionError) —— 继承 PermissionError,工具循环可以看到干净的异常类,格式化回模型作为 role:tool content 字符串。
3 · 敏感信息检测管线
3.1 三层检测架构
src/tools/sensitive_detector.py 的 SensitiveDetector 按顺序执行三层检测,每层的 findings 取并集后去重(_dedup 优先 layer 早 + score 高):
| 层 | 引擎 | 擅长 | 延迟 |
|---|---|---|---|
| Layer 1: gitleaks | 二进制 gitleaks detect(external/gitleaks)或内嵌正则回退 | 高置信凭据/令牌/密钥(AWS key、GitHub PAT、JWT、PEM 私钥…) | 二进制 ~1-3s;正则 <10ms |
| Layer 2: presidio | Microsoft Presidio + spaCy NER(en+zh 双语) | PII 实体(PERSON、EMAIL_ADDRESS、PHONE_NUMBER、CREDIT_CARD、IBAN_CODE、US_SSN…) | 首次 30s-10min(spaCy 冷启动);热路径 ~50-200ms |
| Layer 3: mini-LLM | 当前已禁用(no-op)。用户选择不跑本地小模型做检测。符号保留供 recipe override 接入 | 项目代号、自定义 ID、雇主名等长尾 | N/A |
Layer 1 回退正则:_GITLEAKS_FALLBACK 包含 12 条正则(aws-access-key、aws-secret-key、github-pat、github-fine-grained、openai-key、anthropic-key、slack-token、google-api-key、private-key-pem、jwt、generic-bearer、password-assignment)。
中文本地 PII 正则:_LOCALE_PII_FALLBACK 包含 3 条(cn-mobile、cn-id-card-18、cn-bank-card),各自有验证器(Luhn 校验银行账号、18 位身份证形状校验)抑制误报。
Layer 2 多语言引擎:优先尝试加载 zh_core_web_lg/md/sm/trf + en_core_web_lg/md/sm。若 zh 模型不可用则退到 en-only(不使用 AnalyzerEngine() 默认构造——它会硬编码触发 en_core_web_lg 自动下载)。_has_cjk() 检测输入文本决定默认语言。spaCy 自动下载被 _disable_spacy_auto_download() 全局屏蔽(spacy.cli.download 替换为 no-op)。
3.2 mask_secrets(快速脱敏)
mask_secrets(text) 是纯正则的快速通道——只跑 Layer 1 的 gitleaks 回退 + locale PII,不碰 Presidio 和 mini-LLM。用于:
- memorizer prompt 输入
- audit log 摘录
- shadow dump
- 任何进入云端 LLM 前的文本(
ConversationSession.call(scrub=True))
命中 span 替换为 [REDACTED-<rule>],如 [REDACTED-github-pat]、[REDACTED-cn-mobile]。
3.3 scrub_text / unscrub_text(占位符替换管线)
scrub_text(text) → (scrubbed_text, [(placeholder, original_value), ...]) 把敏感 span 替换为 «SECRET_N» 占位符,返回映射表。unscrub_text 从占位符恢复原文。映射是 call-scoped(返回给调用者,用于 LLM 回复中的还原)。Mini-LLM 故意不在此路径运行——scrub 在 LLM 热路径上,必须同步且廉价。
3.4 commit 管线
检测到 finding 后,SensitiveDetector.commit() 把每个 finding 通过 MemoryWriter._save_secrets_store 加密落盘到 workspace/SECRET.json:
- 命名:
_name_secret()是纯机械命名(无 LLM)——key = "<sanitized_rule>.<kind>"(如github_pat.credential),附带context_hint(局部摘录,原文用<VALUE>遮罩) - 加密:
crypto.encrypt_value(finding.value)→ Fernet 加密 - 存储:
store[key] = {type, aliases, secrets: {field_name: ciphertext}, context_hint, fingerprints: {field_name: {value_sha256, scope, source_url, cookie...}}}
commit_with_namings() 接受预计算的 namings(避免命名 LLM 付两次费),支持 extras 参数写入 fingerprints(浏览器凭据捕获用来记 cookie scope/domain/name/path)。
revert_commit() 回滚:删除字段→空条目则删 key。用于 Case-B undo toast。
3.5 辅助查询
known_fingerprints()→ 返回 SECRET.json 里所有value_sha256,用于捕获流程跳过已存值known_hosts()→ 返回所有有存储凭据的 hostname(从fingerprints[*].source_url和cookie.domain解析)iter_cookie_entries()→ 解密所有 cookie-scoped 条目,返回playwright.context.add_cookies()可直接消费的格式
4 · 加密原语
4.1 HKDF 密钥派生
src/base/crypto.py 实现机器绑定的对称加密:
hostname + "|" + username + "|" + salt_bytes
→ HKDF-SHA256(salt=workspace/.salt, info=b"familiars-secrets-v1", length=32)
→ 32-byte raw key
→ base64url encode
→ Fernet key- HKDF info 字符串:
b"familiars-secrets-v1"—— 版本化的加密原语。轮换它会使workspace/SECRET.json里所有密文永久失效。 - salt:
workspace/.salt,首次使用时os.urandom(32)生成并持久化。 - 机器身份:
platform.node()+os.getenv('USER', os.getenv('USERNAME', 'unknown'))。 - Fernet:AES-128-CBC + HMAC-SHA256,每次加密自带 IV + timestamp + HMAC。
- 密钥缓存:
_cached_key进程内派生一次后复用。
4.2 安全边界
SECRET.json单独拷给别人 → 解不开(缺机器身份)- 整机克隆但没拷
.salt→ 解不开 SECRET.json+.salt都拷但主机名/用户名变了 → 解不开- 重装系统 / 改用户名 / 换电脑 → 自己也解不开,须提前备份
4.3 encrypt_value / decrypt_value
from src.base.crypto import encrypt_value, decrypt_value
ciphertext = encrypt_value("my_github_password") # → "gAAAAAB..."
plaintext = decrypt_value(ciphertext) # → "my_github_password"decrypt_value 抛出 cryptography.fernet.InvalidToken 如果密文损坏、key 不匹配或 TTL 过期(默认无 TTL)。
5 · 设备认证
5.1 设备指纹生成
src/base/auth/device.py:get_device_id() 跨平台生成稳定设备指纹:
| 平台 | 硬件标识 | MAC | 主机名 |
|---|---|---|---|
| macOS | IOPlatformSerialNumber(ioreg 读) | ifconfig en0 | socket.gethostname() |
| Windows | MachineGuid(HKLM\SOFTWARE\Microsoft\Cryptography,OS 安装时生成,跨升级存活) | psutil.net_if_addrs() 首个非回环非全零 MAC | 同 |
| Linux | /etc/machine-id 或 /var/lib/dbus/machine-id | psutil.net_if_addrs() | 同 |
所有部分用 "|" 拼接 → SHA-256 取前 16 hex → dev_<16hex>。
所有硬件查询失败时回退到 hostname alone(明确权衡:两台共享 DESKTOP-XYZ 默认名的机器可能碰撞,但永远不在敌对机器上 raise)。
5.2 AuthManager
src/base/auth/manager.py:AuthManager 单例管理本地用户身份:
- 存储:
workspace/auth.json(目前明文 JSON) register(nickname="")→ 首次创建身份:user_id = "u_" + sha256(device_id)[:12],device_token = "dt_" + secrets.token_hex(16)login()→ 验证auth.json里的device_id与当前get_device_id()一致- 遗留
walletkey 被load()静默忽略
workspace/auth.json 结构:
{
"identity": {
"user_id": "u_a1b2c3...",
"device_id": "dev_abcdef...",
"device_token": "dt_0123456789abcdef...",
"created_at": "2026-08-29T...",
"nickname": ""
}
}6 · Bridge Token 鉴权
6.1 生成与存储
src/base/bridge_token.py:首次启动生成 256-bit token(secrets.token_hex(32)),写入 workspace/.bridge_token,尝试 chmod 0o600(Windows 上 best-effort 忽略 OSError)。进程内 _cached 缓存。
6.2 验证
- 每个 HTTP 请求必须携带
X-Bridge-Tokenheader 或?token=...query 参数 verify_bridge_token(presented)用secrets.compare_digest做常量时间比较/health是唯一免 token 的端点- WebSocket 连接在
accept()之前校验?token=...,未带 token 或错误 → 4001 拒绝(避免触发 5GB VRAM TTS 加载)
6.3 安全边界
浏览器里的随机标签页:
- 无法读取
workspace/.bridge_token(不在任何 web-serving root 下) - 无法通过自签证书白名单(
workspace/.bridge.crt)
剩余风险:能读 workspace 目录的本机进程(IDE、同步盘、备份客户端、恶意扩展)可读走 .bridge_token → 冒充 Electron 前端调用任意 API。缓解:.bridge_token 不要被 git add;workspace/ 不要设为同步盘目录。
6.4 HTTPS 传输
Bridge 默认用 hypercorn + HTTPS/2 + 自签证书(workspace/.bridge.crt + .bridge.key,首次启动自动生成)。FAMILIARS_HTTP_INSECURE=1 回退到明文 HTTP + uvicorn(诊断/回退路径)。
7 · Turn Secrets(最后一公里替换)
src/tools/turn_secrets.py 解决"模型需要调用 get_user_secret 拿到明文但不让明文进模型上下文"的问题:
7.1 生命周期
new turn → begin_turn(agent_id, dialogue_id, turn_id)
模型调用 get_user_secret(name) → register(ciphertext, plaintext) → 返回 "ENC::gAAAA..." 给模型
模型调用 foo(token="ENC::gAAAA...") → substitute_args → 替换为明文 → foo(plaintext)
turn 结束 → end_turn() → 清零 registry(覆盖值后 drop ref)7.2 实现
TurnSecrets持有mapping: dict[str, str](token → plaintext)CIPHERTEXT_PREFIX = "ENC::"—— 所有注册 token 带此前缀,dispatcher 快速判断"本轮无 secret → 跳过 arg walk"- 按
(agent_id, dialogue_id, turn_id)做 ContextVar key,并发生成不串台 substitute_in(value, mapping)递归替换 list/dict/str 中的 ciphertext token,最长优先匹配避免部分重叠clear()先把所有 value 覆盖为""再 drop——best-effort scrub
8 · 沙箱权限
8.1 PERMANENT_DENY
src/tools/security.py 的 PERMANENT_DENY 列表包含真正的逃逸原语(ctypes/pickle/importlib 等),这些永远不可请求。
8.2 可请求权限
os/subprocess/requests 等模块通过 capabilities.extra_modules 声明 + per-(recipe, agent) 审批后可用。ast_guard.py 和 worker.py 镜像 DENY 列表。
8.3 进程闸门
kill_process 只允许 kill _spawned_pids 集合中的 PID(agent 自己通过 open_app 启动的进程)。用户自己开的 chrome → 拒绝。防止 agent 幻觉误关。
9 · 数据上云防线总结
| 防线 | 位置 | 作用 |
|---|---|---|
| mask_secrets | 每个进入 LLM 的文本之前 | gitleaks 正则 + locale PII → [REDACTED-*] |
| scrub_text | ConversationSession.call | 占位符替换 + call-scoped 还原 |
| sensitive_detector | 对话内敏感信息发现 | 三层检测 → 加密 commit 到 SECRET.json |
| turn_secrets | ToolDispatcher dispatch | ENC:: token → 明文,仅进程内替换 |
| 审批门 | 每个工具函数入口 | 风险分级 + 用户审批 |
| Bridge Token | HTTP/WS 中间件 | 256-bit token 阻止外部调用 |
| HTTPS/2 | 传输层 | 自签证书防中间人 |
| HKDF + Fernet | 磁盘存储 | 机器绑定的 AES-128-CBC + HMAC |
用户级文档见仓库根
SECURITY.md(11 章节)。安全漏洞报告流程见SECURITY_REPORT.md。
