feat(backthink,browser): 自排工具面 C1-3 + 浏览器两处偏离收口(X2+X3 同文件一并落)

X2 C1-3(自排工具面与提示词):
- request_think(thinktool.go,照 transcode/tool.go 范式)+ reason 必填/urgency 闭集 + 双守门
  (安全闸 + owner 判定:群聊/陌生人一律拒)
- **成对**进两张唤醒态允许集(quiet + awake;CAPACITY_TOOL_SETS 是整体替换)
- 提示词抽共用段落 + 自排前缀优先(不枚举工具清单);面板加 self_wake 标签与筛选轴
- 4 个变异自检(拿掉 owner 判定/只加一张表/放宽 urgency/前端标签恒等)

X3 收口(与 X2 共用 wire_services.go ⇒ 一并落):
- 删掉第三份 SSRF 实现(guard.go),改由 io.ValidateRequestURL 唯一判定 + 薄门面(保两个错误身份)
- 浏览器会话挂进 a.close(真机:真 Chrome 被关、无残留、生产未扰动)
- a.wireBrowser() 归位到 wire_runtime.go,删掉 ioToolOptions 的临时挂点
- 5 个变异自检

顺带补提交 C1-2 漏掉的 internal/config/runtimeconfig/backthink_test.go(ed50053 漏 add)。
This commit is contained in:
AskaEth committed 2026-10-06 21:16:23 +08:00
1 parent 2bf3b536dd
commit 2d211c2d16
20 files changed
+1568 -176

No files matched your search

-106
View File
@@ -1,106 +0,0 @@
package browser
import (
"context"
"fmt"
"net"
"net/url"
"strings"
)
// ⚠️ **已知偏差(如实登记,不美化)**:design/28 §4 要求「SSRF 复用既有 `io/ssrf`
// 白名单,**不新增第三份实现**」。但既有实现位于 `plugins/tools/io`(`ssrf.go` 的
// `validateRequestURL`)且**未导出**,本切片的文件面不含该包 ⇒ 本片无法直接复用,
// 也不擅自改别人的文件。因此这里落的是**与 `io/ssrf` 同一条策略**的判定(先放行
// 白名单、再按 IP 网段否决;网段口径逐条对齐),并登记为待收口项:
// **收口动作 = 把 `plugins/tools/io` 的 `validateRequestURL` 导出(或提到共享包),
// 删掉本文件**。在收口之前,浏览器侧与 web-fetch 侧的放行/拒绝口径**按同一张表**
// 执行,不存在「一个放行另一个拒绝」的分歧。
//
// 判定口径(与 `io/ssrf` 逐条对齐):
// - 只接受 `http` / `https`;
// - 命中调用方白名单的主机**直接放行**(同源自取:本地 searxng、本机 upload 路由);
// - 字面 IP 落在回环/私网/链路本地/组播/未指定 ⇒ 拒绝;
// - 主机名先解析,**任一条**解析结果落在上面那些网段 ⇒ 拒绝(防 DNS rebinding 的
// 第一跳;真正的 rebinding 需要连上时再校验,既有两份实现都没做,本片不越界)。
//
// 为什么浏览器侧必须有这道闸:导航是**服务端代取用户/模型给的任意 URL**,不过闸就
// 等于把云元数据端点(`169.254.169.254`)、内网管理面暴露给一次提示词注入
// (design/28 §4 第 1 条;M3 面板入口同样过闸)。
func GuardURL(ctx context.Context, rawURL string, allowed []string) (*url.URL, error) {
if strings.TrimSpace(rawURL) == "" {
return nil, fmt.Errorf("%w: url 为空", ErrBlockedURL)
}
u, err := url.Parse(rawURL)
if err != nil {
return nil, fmt.Errorf("%w: 解析 url: %v", ErrBlockedURL, err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return nil, fmt.Errorf("%w: 不支持协议 %q", ErrBlockedURL, u.Scheme)
}
host := u.Hostname()
if host == "" {
return nil, fmt.Errorf("%w: url 没有主机名", ErrBlockedURL)
}
if hostAllowed(host, allowed) {
return u, nil
}
if ip := net.ParseIP(host); ip != nil {
if deniedIP(ip) {
return nil, fmt.Errorf("%w: %s 属回环/私网/链路本地地址", ErrBlockedURL, host)
}
return u, nil
}
addrs, err := net.DefaultResolver.LookupHost(ctx, host)
if err != nil {
return nil, fmt.Errorf("%w: 解析 %q: %v", ErrBlockedURL, host, err)
}
for _, a := range addrs {
if ip := net.ParseIP(a); ip != nil && deniedIP(ip) {
return nil, fmt.Errorf("%w: %q 解析到 %q", ErrBlockedURL, host, a)
}
}
return u, nil
}
// deniedIP 报告 ip 是否属于守卫默认拒绝的网段:回环、私网、链路本地(单播/组播)、
// 未指定。口径与 `plugins/tools/io/ssrf.go` 的 `isDeniedIP` 逐条相同。
func deniedIP(ip net.IP) bool {
if ip == nil {
return false
}
return ip.IsLoopback() ||
ip.IsPrivate() ||
ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() ||
ip.IsUnspecified()
}
// hostAllowed 报告 host 是否命中白名单(逐条按主机名比较,大小写不敏感,端口忽略)。
func hostAllowed(host string, allowed []string) bool {
h := strings.ToLower(strings.TrimSpace(host))
if h == "" {
return false
}
for _, a := range allowed {
if strings.EqualFold(hostOf(a), h) {
return true
}
}
return false
}
// hostOf 从白名单条目里取主机名:允许写成 `host`、`host:port` 或完整 URL。
func hostOf(entry string) string {
raw := strings.TrimSpace(entry)
if raw == "" {
return ""
}
if u, err := url.Parse(raw); err == nil && u.Hostname() != "" {
return u.Hostname()
}
if host, _, err := net.SplitHostPort(raw); err == nil {
return host
}
return raw
}
+18
View File
@@ -2,10 +2,13 @@ package browser
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
)
// TestGuardURL_DeniesLoopbackAndPrivate 钉住守卫的**默认拒绝面**:回环、私网、
@@ -88,3 +91,18 @@ func TestGuardURL_RejectsHostResolvingToPrivate(t *testing.T) {
t.Errorf("httptest 地址 %s 应被拒(字面回环)", hostIP)
}
}
// TestGuardURL_KeepsBothErrorIdentities 钉住收口(切片 X3)之后的**双错误身份**:
// 判定本体搬去了 `io.ValidateRequestURL`,但本包的契约没动 —— 组合根/工具层仍按
// `ErrBlockedURL` 分类降级(`ReasonBlockedURL` 与它同源),而排障时能顺着链回溯到**唯一**
// 判定源(`io.ErrSSRF`)。少任何一个身份,「收口只搬判定、不动契约」就只是句口号。
func TestGuardURL_KeepsBothErrorIdentities(t *testing.T) {
t.Parallel()
_, err := GuardURL(context.Background(), "http://169.254.169.254/latest/meta-data/", nil)
if !errors.Is(err, ErrBlockedURL) {
t.Errorf("拒绝丢了本包契约身份 ErrBlockedURL: %v", err)
}
if !errors.Is(err, iotools.ErrSSRF) {
t.Errorf("拒绝丢了判定源身份 iotools.ErrSSRF: %v", err)
}
}
+41
View File
@@ -0,0 +1,41 @@
package browser
import (
"context"
"fmt"
"net/url"
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
)
// GuardURL 是浏览器侧访问 SSRF 判定的**唯一入口**,判定本体**只有一份**:
// `plugins/tools/io.ValidateRequestURL`(design/28 §4:「复用既有 `io/ssrf` 白名单,
// **不新增第三份实现**」)。
//
// **为什么这里只留一层薄门面(切片 X3 的收口)**:本包原先自带一份与 io 同策的实现
// (`guard.go`:自己的 scheme 判定 + 网段表 + 白名单比对),只因为当时 io 侧的
// `validateRequestURL` 没导出、该包又不在那一片的文件面里。两份同策代码的危险不在
// 当下的结论,而在**以后只改一处** —— 改完两边静默分叉,浏览器侧的导航就成了绕过面。
// 收口后本文件只做两件事:
//
// 1. 调 `io.ValidateRequestURL`(**唯一**判定源:scheme → 白名单短路 → 字面 IP 网段
// 否决 → 解析后再按同一张表否决);
// 2. 把它的错误翻成本包对外的 `ErrBlockedURL` —— 这是**契约**不是判定:
// `ErrBlockedURL` / `ReasonBlockedURL` 是工具层与组合根(含后续 M3 面板入口)用来
// 分类降级与记日志的标识,收口只搬判定,不动契约。
//
// 两个错误身份都保住(`%w:%w`):`errors.Is(err, ErrBlockedURL)` 成立(本包契约),
// 网段/解析类拒绝上 `errors.Is(err, iotools.ErrSSRF)` 同样成立(判定源),io 侧的原因
// 文案(哪个网段、解析到哪个地址)一字不丢地进日志。形状错误(空 URL、非 http(s))
// 在 io 侧本就不带 ErrSSRF,那几档只有本包身份。
//
// 为什么浏览器侧必须有这道闸:导航是**服务端代取用户/模型给的任意 URL**,不过闸就
// 等于把云元数据端点(`169.254.169.254`)、内网管理面暴露给一次提示词注入
// (design/28 §4 第 1 条;M3 面板入口同样过闸)。
func GuardURL(ctx context.Context, rawURL string, allowed []string) (*url.URL, error) {
u, err := iotools.ValidateRequestURL(ctx, rawURL, allowed)
if err != nil {
return nil, fmt.Errorf("%w:%w", ErrBlockedURL, err)
}
return u, nil
}