## 实测经过(全面测 MCP 时发现) `connect_to_server(gateway_url="http://evil.example", agent_key="x")` 返回「已连接」—— 看起来像模型改了网关坐标。 查下去坐标**改不了**:身份只从 AgentAuth 的 context 取,参数被忽略 (schema 是 `properties: {}`)。**所以不是安全问题。** 但它是另一种毛病:**误传/写错的参数被静默吞掉**,模型收到「成功」后 不会知道自己传的东西根本没生效。实测发现 **11 个工具全部**没设 `additionalProperties: false`。 ## 两道都补 1. **schema 层**:`objSchema` 加 `additionalProperties: false` —— 一处改动覆盖全部 11 个工具。 2. **运行时**:`unknownArgs` / `declaredArgs` 在 `tools/call` 里再拒一次。 schema 只是**声明**,是否生效取决于宿主是否遵守 ⇒ 这一道不依赖宿主。 报错文案点名参数并列出可用参数,让模型能自我纠正: 工具 connect_to_server 没有这些参数:agent_key、gateway_url(可用:(无参数)) `unknownArgs` 只在 schema 自己声明了 `additionalProperties:false` 时才判 —— 否则等于替宿主做主,把合法的透传参数也拒了(有判据钉住)。 ## 判据自己假绿了一次 变异 `if false && len(bad) > 0`(短路调用点)→ **0 红**。 因为 `TestUnknownArgsOnlyWhenSchemaOptsIn` 测的是 `unknownArgs` **这个函数**, 而**函数正确 ≠ 它被接上了**。这是本轮第五次栽在「形状判据测不到接线」上。 ⇒ 补了端到端一格:从 HTTP 进去,看客户端实际收到什么(并断言绝不能是 「已连接」—— 那正是要修的假成功)。重测同一变异立刻转红。 判据走真注册表(`NewTools().RegisterAll`)而非复制一份清单 —— 复制的那份会与真实工具面漂移,而漂移方向恰好是「新工具没被判据覆盖」。 ## 顺带纠正上一轮的一个错误结论 上轮我说 `read_mail` 与 `read_thread` 拒绝措辞不一致是缺陷。**那是错的。** `agent_discovery.go:414` 明确丢掉 reason,注释写着「拒绝原因在 403 文案上 合流成同一句,反而少泄一点信息」—— 那是**刻意的安全取舍**,不告诉调用方 到底是跨工作区还是没参与过(两者指向同一个可枚举的信息)。 而 `read_mail` 那句话是用户 2026-09-15 亲自订正过的(原注释:旧文案让 zcode 推断出「那五位 agent 不存在」,把错误结论写进了给人看的信里)。 ⇒ **不改**。把刻意的安全设计当缺陷「修」掉,比留着更糟。 14 包全绿。
288 lines
9.8 KiB
Go
288 lines
9.8 KiB
Go
package mcp
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"net/url"
|
||
"strings"
|
||
|
||
"github.com/go-chi/chi/v5"
|
||
)
|
||
|
||
// Tools 构造网关的 11 个 MCP 工具。
|
||
//
|
||
// # 为什么是**包装 handler**而不是直调 repo
|
||
//
|
||
// 早先那版 MCP 在独立进程里用 HTTP 调本网关,于是工具语义是**手抄**的一份:
|
||
// 参数、收窄、配额、错误文案各写一遍。实测已抓到后果 ——
|
||
// `connect_to_server` 只发 `X-Agent-Secret` 头,而 `/agent/register` 只认
|
||
// Bearer 或 body 里的 `secret`,于是 secret-only 的 Agent 调它必然 400,
|
||
// 而模型看到 401/400 拼不出该改什么。
|
||
//
|
||
// 包装 handler 之后:**只有一份语义**。工作区收窄(`GetInbox` 缺 workspace
|
||
// 直接 400)、会话收窄(`agentScope`)、Agent↔Agent 冷静期、配额、
|
||
// 附件保护目录 —— 全部是同一条代码路径,不是复述。
|
||
//
|
||
// # 身份从哪来
|
||
//
|
||
// 不从工具参数里取身份。`AgentAuth` 已把身份放进 request context
|
||
// (`middleware.AgentNameKey`),每个内部请求都带上它。所以工具**无法**
|
||
// 通过参数冒充别的 Agent —— 那正是 2026-10-02 修掉的越权形状
|
||
// (`AgentMayReadSession` 的 `if scope == nil { return true }`)。
|
||
type Tools struct{}
|
||
|
||
// NewTools 造工具集。
|
||
func NewTools() *Tools { return &Tools{} }
|
||
|
||
// RegisterAll 把 11 个工具注册进端点。
|
||
//
|
||
// 工具名与参数名与 pi / dsh / opencode / zcode 四桥**逐字一致** —— 同一件事
|
||
// 在任何平台上必须是同一种做法,否则会出现「只在这个平台上模型不会回信」
|
||
// 这类单平台复现、排查代价最高的问题。
|
||
func (t *Tools) RegisterAll(s *Server) {
|
||
s.RegisterAll(
|
||
t.ReadInbox(), t.ReadMail(), t.ReadThread(), t.SendMail(),
|
||
t.ForwardMail(), t.UploadAttachment(), t.DownloadAttachment(),
|
||
t.SuggestAddress(), t.ListContacts(), t.SessionParticipants(),
|
||
t.ConnectToServer(),
|
||
)
|
||
}
|
||
|
||
// invoke 调一个 handler,拿到状态码与解码后的 JSON。
|
||
//
|
||
// 这是全部工具共用的骨架:造请求 → 交给 handler → 解读结果。
|
||
// handler 用 httptest.ResponseRecorder 收集(它是标准库类型,零依赖),
|
||
// 因为我们要的是"handler 的语义",不是真的走网络。
|
||
func invoke(ctx context.Context, h http.HandlerFunc, req *http.Request) (int, map[string]any, string) {
|
||
// ★ 不要在这里 `req = req.WithContext(ctx)`。
|
||
//
|
||
// req 身上已经挂了两样东西,而用 ctx 重建会**丢掉后面挂的那一样**:
|
||
// - 身份(middleware.AgentNameKey):由 newRequest 挂上
|
||
// - 路径参数(chi.RouteCtxKey):由 withRouteParams 挂上
|
||
//
|
||
// withRouteParams 之后调 WithContext(ctx),chi 的 RouteContext 就没了 ⇒
|
||
// handler 里 chi.URLParam 恒为空 ⇒ 报「Invalid id」。
|
||
// ctx 是**外层请求**的 context,而 req 已经是它的派生物(newRequest 里
|
||
// 做过一次 WithContext);所以这里什么都不做才是对的。
|
||
// (判据:TestPathParamsReachHandlers)
|
||
_ = ctx
|
||
rec := httptest.NewRecorder()
|
||
h(rec, req)
|
||
var out map[string]any
|
||
raw := rec.Body.Bytes()
|
||
_ = json.Unmarshal(raw, &out)
|
||
return rec.Code, out, string(raw)
|
||
}
|
||
|
||
// render 把 handler 的响应转成模型看的文本。
|
||
//
|
||
// 规则:成功时给可读摘要(而**不是**整坨 JSON —— 模型读 50 封邮件的 JSON
|
||
// 既费 token 又容易看错行);失败时**原样带上 handler 的错误文案**,
|
||
// 那是让人/模型能改道的信息,不能吞掉。
|
||
func render(name string, status int, payload map[string]any, raw string) (string, error) {
|
||
if status >= 200 && status < 300 {
|
||
return summarize(name, payload)
|
||
}
|
||
msg := errorMessage(payload)
|
||
if msg == "" {
|
||
msg = strings.TrimSpace(raw)
|
||
}
|
||
if msg == "" {
|
||
msg = fmt.Sprintf("HTTP %d", status)
|
||
}
|
||
return "", fmt.Errorf("%s", msg)
|
||
}
|
||
|
||
func errorMessage(payload map[string]any) string {
|
||
if payload == nil {
|
||
return ""
|
||
}
|
||
for _, k := range []string{"error", "message", "suggestion"} {
|
||
if v, ok := payload[k].(string); ok && v != "" {
|
||
return v
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// ---- 参数取值 ----
|
||
//
|
||
// 工具参数一律宽容:数字既能是 JSON number 也能是字符串(模型两种都发过)。
|
||
// 严格解码会让一个本来能用的调用失败,而失败信息("类型不匹配")对模型没用。
|
||
|
||
func argStr(args map[string]any, key string) string {
|
||
if v, ok := args[key]; ok {
|
||
switch t := v.(type) {
|
||
case string:
|
||
return t
|
||
case float64:
|
||
// 纯整数的 number 当字符串用(id 常见地被模型写成数字)
|
||
if t == float64(int64(t)) {
|
||
return fmt.Sprintf("%d", int64(t))
|
||
}
|
||
return fmt.Sprintf("%v", t)
|
||
case bool:
|
||
return fmt.Sprintf("%t", t)
|
||
case nil:
|
||
return ""
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func argInt(args map[string]any, key string, def int) int {
|
||
s := argStr(args, key)
|
||
if s == "" {
|
||
return def
|
||
}
|
||
var n int
|
||
if _, err := fmt.Sscanf(s, "%d", &n); err == nil {
|
||
return n
|
||
}
|
||
return def
|
||
}
|
||
|
||
// argBool 取布尔。字符串 "true"/"false" 也认。
|
||
func argBool(args map[string]any, key string) bool {
|
||
switch v := args[key].(type) {
|
||
case bool:
|
||
return v
|
||
case string:
|
||
return strings.EqualFold(strings.TrimSpace(v), "true")
|
||
}
|
||
return false
|
||
}
|
||
|
||
// argStrList 取字符串数组(逗号分隔的字符串也认)。
|
||
func argStrList(args map[string]any, key string) []string {
|
||
switch v := args[key].(type) {
|
||
case []any:
|
||
out := make([]string, 0, len(v))
|
||
for _, item := range v {
|
||
if s := argStr(map[string]any{"v": item}, "v"); s != "" {
|
||
out = append(out, s)
|
||
}
|
||
}
|
||
return out
|
||
case string:
|
||
parts := strings.FieldsFunc(v, func(r rune) bool {
|
||
return r == ',' || r == ';' || r == ' ' || r == '\n'
|
||
})
|
||
return parts
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// withRouteParams 把路径参数注入 request context。
|
||
//
|
||
// ★ 为什么必须这么做:被包装的 handler 用 `chi.URLParam(r, "id")` 取路径参数
|
||
// (`pathUUID` → `uuid.Parse(chi.URLParam(r, "id"))`)。而我们用
|
||
// httptest.NewRequest 造的请求**没有经过 chi 的路由**,URLParam 恒为空串
|
||
// ⇒ 任何带路径参数的工具(read_mail / read_thread / forward_mail /
|
||
// download_attachment / session_participants)都会报「Invalid id」。
|
||
//
|
||
// 这个 bug 是**端到端**撞出来的:主人读自己的信与越权者读别人的信**都**返回
|
||
// 「Invalid id」。对照实验立刻说明这不是权限问题而是参数没到位 —— 如果只看
|
||
// 越权那一次,会误判成「收得太紧」,进而把正确的收窄改松。
|
||
//
|
||
// 用 chi 自己的 RouteContext 注入,而不是自造 context key:handler 读的是
|
||
// chi.URLParam,两边必须说同一种话。
|
||
func withRouteParams(r *http.Request, params map[string]string) *http.Request {
|
||
if len(params) == 0 {
|
||
return r
|
||
}
|
||
rctx := chi.NewRouteContext()
|
||
for k, v := range params {
|
||
rctx.URLParams.Add(k, v)
|
||
}
|
||
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
|
||
}
|
||
|
||
// objSchema 造一个 object 类型的入参 schema。
|
||
/*
|
||
objSchema 构造工具的 inputSchema。
|
||
|
||
★ `additionalProperties: false` 是 2026-10-04 实测后加的,不是洁癖:
|
||
|
||
实测 `connect_to_server(gateway_url="http://evil.example", agent_key="x")`
|
||
返回「已连接」—— 看起来像模型改了网关坐标。查下去它确实改不了
|
||
(身份只从 AgentAuth 的 context 取,参数被忽略),所以**不是安全问题**。
|
||
|
||
但它是另一种毛病:**误传/写错的参数被静默吞掉**,模型收到「成功」后
|
||
不会知道自己传的东西根本没生效。实测里 11 个工具**全部**没设这个标记。
|
||
|
||
⇒ 未声明的参数必须在 schema 层就拒绝,让模型立刻看到
|
||
「这个工具没有这个参数」而不是拿到一个假的成功。
|
||
(跨版本宿主仍可能不发 `additionalProperties`,
|
||
故 tools/call 侧另有一道运行时校验,见 checkToolArgs。)
|
||
*/
|
||
func objSchema(props map[string]any, required ...string) map[string]any {
|
||
s := map[string]any{
|
||
"type": "object",
|
||
"properties": props,
|
||
"additionalProperties": false,
|
||
}
|
||
if len(required) > 0 {
|
||
s["required"] = required
|
||
}
|
||
return s
|
||
}
|
||
|
||
func strProp(desc string) map[string]any {
|
||
return map[string]any{"type": "string", "description": desc}
|
||
}
|
||
func numProp(desc string) map[string]any {
|
||
return map[string]any{"type": "integer", "description": desc}
|
||
}
|
||
func boolProp(desc string) map[string]any {
|
||
return map[string]any{"type": "boolean", "description": desc}
|
||
}
|
||
|
||
func arrayProp(desc string) map[string]any {
|
||
return map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": desc}
|
||
}
|
||
|
||
// 只读标注。宿主据此算风险等级并在 plan 档放行(漏传会被全拒)。
|
||
var readOnly = map[string]any{"readOnlyHint": true, "destructiveHint": false}
|
||
|
||
// 写但不破坏性(发信、转发:会改变别人的收件箱,但不删数据)。
|
||
var writeSafe = map[string]any{"readOnlyHint": false, "destructiveHint": false}
|
||
|
||
// hasWorkspaceArg 在没有 workspace 时给出**带例子**的错误。
|
||
//
|
||
// 直接复用 GetInbox 的错误文案即可(包装 handler 的好处):
|
||
// 那是唯一的口径,模型见一次就记住。
|
||
func newRequest(ctx context.Context, method, target string, body any) *http.Request {
|
||
var r *http.Request
|
||
if body != nil {
|
||
buf, _ := json.Marshal(body)
|
||
r = httptest.NewRequest(method, target, bytes.NewReader(buf))
|
||
r.Header.Set("Content-Type", "application/json")
|
||
} else {
|
||
r = httptest.NewRequest(method, target, nil)
|
||
}
|
||
// 身份从调用链的 context 继承(AgentAuth 放进去的)。
|
||
r = r.WithContext(ctx)
|
||
return r
|
||
}
|
||
|
||
// withQuery 在 URL 上加查询参数。
|
||
func withQuery(target string, kv map[string]string) string {
|
||
u, err := url.Parse(target)
|
||
if err != nil {
|
||
return target
|
||
}
|
||
q := u.Query()
|
||
for k, v := range kv {
|
||
if v != "" {
|
||
q.Set(k, v)
|
||
}
|
||
}
|
||
u.RawQuery = q.Encode()
|
||
return u.String()
|
||
}
|