Skip to main content

futu_mcp/
tool_auth.rs

1//! MCP caller-auth helper types and pure policy decisions.
2//!
3//! This module deliberately avoids concrete `#[tool]` handlers.  It keeps the
4//! reusable identity snapshot / Bearer parsing / early trade-scope policy out of
5//! `tools.rs`, while `tools.rs` remains the thin dispatch surface.
6
7use crate::tools::FutuServer;
8use crate::{guard, handlers};
9use futu_auth::CheckCtx;
10use rmcp::{RoleServer, service::RequestContext};
11
12mod policy;
13pub(crate) use policy::{
14    CallerSnapshot, EarlyTradeScopeDecision, decide_early_trade_scope, http_bearer_token,
15    outcome_key_id_from_snapshot, same_reusable_identity,
16};
17use policy::{audit_reject_with_context, mcp_audit_context, scope_label};
18
19impl FutuServer {
20    // v1.4.58 Phase C3 删除:`Self::err` + `Self::wrap` v1.4.42 遗留的 JSON
21    // `"isError": true` content-marker hack。所有 tool handler 已迁移到
22    // `Result<String, String>` 返回类型,rmcp 自动 set MCP spec 的
23    // `CallToolResult.is_error = Some(true)` on Err variant(top-level
24    // envelope 字段,对齐 MCP 协议)。用 `Self::tool_err` / `Self::wrap_result`
25    // 取代。
26    //
27    // v1.4.89 #7 "MCP isError 根治" 确认已落地 in-place —— 无需改 signature
28    // 到 `Result<CallToolResult, McpError>`。rmcp 1.4.0 提供 blanket
29    // `impl<T: IntoCallToolResult, E: IntoCallToolResult> IntoCallToolResult
30    // for Result<T, E>`(见 rmcp src/handler/server/tool.rs:100-112),
31    // `Err(String)` 分支自动 set `result.is_error = Some(true)` + content
32    // 保留 JSON body(老 client 兼容"error"/"status"字段双信号)。v1.4.89
33    // 回归测试同时锁定协议层 is_error 与兼容 JSON 契约。
34
35    /// v1.4.58 Phase C1: 新 helper — 返 `Result<String, String>` 让 rmcp 自动
36    /// set top-level `CallToolResult.is_error = Some(true)`(对齐 MCP spec)。
37    ///
38    /// 迁移策略(C1/C2/C3 拆 3 commit,都进 v1.4.58 一个版本):
39    /// - **C1**:加此 helper + 迁移 5-10 pilot handlers
40    /// - **C2**:批量迁移剩余 70+ handlers
41    /// - **C3**:删除 `Self::err` / `Self::wrap` String hack(v1.4.42 遗留)
42    ///
43    /// Client 双信号(向后兼容):
44    /// - Top-level `is_error: true`(MCP spec 正确方式)
45    /// - Content JSON 里仍含 `{"error": msg, ...}`(老 client 兼容)
46    ///
47    /// rmcp `IntoCallToolResult for Result<T, E>` impl 自动把 Err 转成
48    /// `CallToolResult { is_error: Some(true), content: [msg.into_contents()] }`。
49    pub(crate) fn tool_err(msg: impl std::fmt::Display) -> std::result::Result<String, String> {
50        Err(serde_json::json!({
51            "error": msg.to_string(),
52            "status": "error",
53        })
54        .to_string())
55    }
56
57    /// v1.4.106 D1 5d: MCP-specific reject translator (rich context: tool + audit_key_id).
58    ///
59    /// MCP 返 JSON-encoded `String` (rmcp `Err(String)` → 自动 `CallToolResult
60    /// { is_error: Some(true), ... }`, 见 `tool_err` 注释).
61    ///
62    /// **rich context 路径** (require_acc_read_with_acc_id / require_trading 等):
63    /// 把 `kind` + `reason` + `tool` + `audit_key_id` 翻成 user-friendly
64    /// MCP error JSON. 这里保留 tool name + audit_key_id, 让 LLM agent 知道
65    /// 是哪个 tool 哪把 key.
66    ///
67    /// **不变量** (与 v1.4.105 byte-identical):
68    /// - Unauthenticated → "API key required for {tool}: ..."
69    /// - Forbidden → "API key {audit_key_id:?} forbidden: {reason}"
70    ///   (注意: reason 在 MCP 路径**保留**, 与 REST/gRPC generic 不同; MCP 是
71    ///   LLM agent 调试场景, 反推风险低 + agent 需要清晰 hint 来纠正参数)
72    /// - RateLimited → "rate limit: {reason}"
73    /// - 其他 → reason
74    fn mcp_reject_to_json(
75        kind: futu_auth_pipeline::RejectKind,
76        reason: String,
77        tool: &str,
78        audit_key_id: &str,
79    ) -> String {
80        use futu_auth_pipeline::RejectKind;
81        let prefix = match kind {
82            RejectKind::Unauthenticated => format!(
83                "API key required for {tool}: provide via tool args api_key, \
84                 HTTP Authorization Bearer, or set FUTU_MCP_API_KEY"
85            ),
86            RejectKind::Forbidden => {
87                // scope 不够 OR acc_id 不在白名单 — pipeline reason 已含细节.
88                // MCP 路径保留 reason (LLM agent 调试用), 不同 REST/gRPC 的 generic.
89                format!("API key {audit_key_id:?} forbidden: {reason}")
90            }
91            RejectKind::RateLimited => format!("rate limit: {reason}"),
92            _ => reason.clone(),
93        };
94        serde_json::json!({
95            "error": prefix,
96            "status": "error",
97        })
98        .to_string()
99    }
100
101    /// v1.4.58 Phase C1: `wrap` 新版 —— 返 `Result<String, String>`。C2 批量迁移时使用。
102    pub(crate) fn wrap_result<E: std::fmt::Display>(
103        res: std::result::Result<String, E>,
104    ) -> std::result::Result<String, String> {
105        match res {
106            Ok(s) => Ok(s),
107            Err(e) => Self::tool_err(e),
108        }
109    }
110
111    /// v1.4.58 Phase C2: 从 `Result<String, String>` 抽 string content 作 &str。
112    /// 用于 `guard::emit_trade_outcome` 等接受 &str 的 side-effect observers —
113    /// 无论 Ok/Err 都有 string 可引用。
114    pub(crate) fn result_as_str(r: &std::result::Result<String, String>) -> &str {
115        match r {
116            Ok(s) | Err(s) => s.as_str(),
117        }
118    }
119
120    pub(crate) async fn client_or_err(
121        &self,
122    ) -> std::result::Result<std::sync::Arc<futu_net::client::FutuClient>, String> {
123        self.state.client().await.map_err(|e| {
124            // MED-1 修(code review):error 返 JSON 格式和 tool_err 对齐,
125            // 让 agent 看到的 error shape 一致(tool_err / scope reject / connect
126            // 三类 error 都是 JSON with "error" + "status" fields)
127            serde_json::json!({
128                "error": format!("gateway connect failed: {e}"),
129                "status": "error",
130            })
131            .to_string()
132        })
133    }
134
135    /// Common MCP read path: caller-specific guard first, then gateway client.
136    ///
137    /// Used by read-only tools that do not need the returned caller snapshot for
138    /// account/card filtering. Account-specific tools still call
139    /// `require_acc_read_with_acc_id` directly so they can pass the same snapshot
140    /// into account locator / response filtering.
141    pub(crate) async fn read_client_or_err(
142        &self,
143        tool: &'static str,
144        req_ctx: &RequestContext<RoleServer>,
145        api_key_override: Option<&str>,
146        acc_id: Option<u64>,
147    ) -> std::result::Result<std::sync::Arc<futu_net::client::FutuClient>, String> {
148        self.require_acc_read_with_acc_id(tool, req_ctx, api_key_override, acc_id)?;
149        self.client_or_err().await
150    }
151
152    /// v1.4.103 (codex 51 F1 / 52 F1 / 53 F1 / 54 F4 / 58 F1 — B5 + B6):
153    /// per-request **caller-specific** scope 守卫 + acc_id 白名单 check.
154    ///
155    /// 旧 `require_tool_scope` 只看 process-wide `state.authed_key` (startup
156    /// 捕获), HTTP 客户端带窄权限 Bearer 时 read tool 仍按 startup key 放行 —
157    /// **跨账户 leak**.
158    ///
159    /// 本方法接 `req_ctx` (rmcp request context) + 可选 `api_key_override`
160    /// (tool args 里的 api_key 字段, 与 trade write tools 一致) + 可选 `acc_id`,
161    /// 优先级: api_key_override > HTTP Authorization Bearer > startup key.
162    ///
163    /// 解析得到 caller-specific KeyRecord 后:
164    /// 1. 检查 scope (基于 caller 的 scope, 不是 startup 的)
165    /// 2. 若 acc_id 提供 + caller key 有 allowed_acc_ids → 检查 acc_id ∈ allowed
166    ///
167    /// stdio mode (无 Bearer) + 无 api_key_override → fall back 到 startup key
168    /// 行为, 不破坏 stdio 用户体验.
169    ///
170    /// 返 Some(error_json) 拒绝, None 放行.
171    ///
172    /// ## v1.4.104 阶段 4: pipeline 委托
173    ///
174    /// caller-specific KeyRecord 解析 + Bearer 不存在的 fail-closed 仍在本地
175    /// (要保留 v1.4.103 codex F4 verbose error message). scope check + acc_id
176    /// 白名单 + audit emit 委托给 [`futu_auth_pipeline::authenticate_request`]
177    /// (跨 surface 共享: gRPC server.rs / WS ws_listener.rs / REST auth.rs 同源).
178    /// LoC 减 ~80 行, 行为与 v1.4.103 byte-identical.
179    pub(crate) fn require_acc_read_with_acc_id(
180        &self,
181        tool: &'static str,
182        req_ctx: &RequestContext<RoleServer>,
183        api_key_override: Option<&str>,
184        acc_id: Option<u64>,
185    ) -> Result<CallerSnapshot, String> {
186        let needed_scope = match guard::scope_for_tool(tool) {
187            Some(guard::ToolScope::Read(scope)) => scope,
188            Some(guard::ToolScope::Trade) => {
189                return Err(serde_json::json!({
190                    "error": format!(
191                        "internal error: {tool} is a trade tool, must use require_trading"
192                    ),
193                    "status": "error",
194                })
195                .to_string());
196            }
197            None => {
198                return Err(serde_json::json!({
199                    "error": format!("unknown MCP tool {tool:?}"),
200                    "status": "error",
201                })
202                .to_string());
203            }
204        };
205        self.authenticate_mcp_read(tool, needed_scope, req_ctx, api_key_override, acc_id, false)
206    }
207
208    /// Re-authenticate a long-lived 2026 resource operation against a stable
209    /// transport identity. HTTP requires the request Bearer; stdio requires the
210    /// startup key id. Tool-argument overrides are intentionally excluded: they
211    /// cannot be reproduced by later resources/read or subscriptions/listen.
212    pub(crate) fn require_push_continuation(
213        &self,
214        operation: &'static str,
215        req_ctx: &RequestContext<RoleServer>,
216    ) -> Result<CallerSnapshot, String> {
217        self.authenticate_mcp_read(
218            operation,
219            futu_auth::Scope::AccRead,
220            req_ctx,
221            None,
222            None,
223            true,
224        )
225    }
226
227    /// Read-only capability probe for MCP 2026 resource continuations.
228    ///
229    /// Discovery must not spend quota or emit an allow/reject audit record. It
230    /// therefore resolves only the transport-stable identity and applies the
231    /// same machine/expiry/`acc:read` facts used by the enforcing continuation
232    /// path. The actual resource method re-authenticates and audits immediately
233    /// before touching queue/listener state.
234    pub(crate) fn has_reusable_push_identity(&self, req_ctx: &RequestContext<RoleServer>) -> bool {
235        let is_http = req_ctx.extensions.get::<http::request::Parts>().is_some();
236        let record = if is_http {
237            http_bearer_token(req_ctx)
238                .filter(|token| !token.is_empty())
239                .and_then(|token| self.state.key_store().verify(&token))
240        } else {
241            self.state.authed_key().and_then(|startup| {
242                self.state
243                    .key_store()
244                    .get_by_id_for_current_machine(&startup.id)
245            })
246        };
247
248        record.is_some_and(|record| {
249            !record.is_expired(chrono::Utc::now())
250                && record.scopes.contains(&futu_auth::Scope::AccRead)
251        })
252    }
253
254    /// Prove that the identity used by `futu_sub_acc_push` can be reproduced by
255    /// later resource requests. This is evaluated before gateway connection and
256    /// daemon dispatch, so mismatch cannot leave a remote orphan subscription.
257    pub(crate) fn require_same_push_continuation_identity(
258        &self,
259        req_ctx: &RequestContext<RoleServer>,
260        tool_snapshot: &CallerSnapshot,
261    ) -> Result<CallerSnapshot, String> {
262        let continuation = self.require_push_continuation("futu_sub_acc_push", req_ctx)?;
263        if !same_reusable_identity(
264            tool_snapshot.key_id.as_deref(),
265            continuation.key_id.as_deref(),
266        ) {
267            return Err(serde_json::json!({
268                "error": "futu_sub_acc_push: api_key identity differs from the reusable HTTP Bearer / stdio startup identity",
269                "status": "error",
270                "hint": "use the same API key for registration and subsequent resources/read or subscriptions/listen requests",
271            })
272            .to_string());
273        }
274        Ok(continuation)
275    }
276
277    fn authenticate_mcp_read(
278        &self,
279        operation: &'static str,
280        needed_scope: futu_auth::Scope,
281        req_ctx: &RequestContext<RoleServer>,
282        api_key_override: Option<&str>,
283        acc_id: Option<u64>,
284        require_stable_identity: bool,
285    ) -> Result<CallerSnapshot, String> {
286        // v1.4.106 D1 5d: RejectKind 已移到 mcp_reject_to_json (rich-context),
287        // 此 fn 内不再直接 match RejectKind.
288        use futu_auth_pipeline::{
289            AuthDecision, AuthEnvelope, Credential, Endpoint, SurfaceId, authenticate_request,
290        };
291
292        let audit_ctx = mcp_audit_context(req_ctx);
293        let header_token = http_bearer_token(req_ctx);
294        let is_http = req_ctx.extensions.get::<http::request::Parts>().is_some();
295        let plaintext_override = if require_stable_identity {
296            header_token.as_deref().filter(|s| !s.is_empty())
297        } else {
298            api_key_override
299                .filter(|s| !s.is_empty())
300                .or(header_token.as_deref())
301                .filter(|s| !s.is_empty())
302        };
303
304        if require_stable_identity && is_http && plaintext_override.is_none() {
305            audit_reject_with_context(
306                &audit_ctx,
307                operation,
308                "<missing-bearer>",
309                "modern push continuation requires HTTP Bearer",
310            );
311            return Err(serde_json::json!({
312                "error": format!("{operation}: HTTP Bearer required for modern push continuation"),
313                "status": "error",
314            })
315            .to_string());
316        }
317
318        // v1.4.103 codex F4 (P1) fail-closed: caller-supplied Bearer/api_key
319        // verify 失败 → **立即 reject** 不 fall back 到 startup key (跨租户 leak).
320        // 这一段保留在本地 (不进 pipeline) 是为了保留 v1.4.103 verbose error JSON
321        // (LLM agent 看到 "v1.4.103 codex F4 fail-closed" 等明确指引).
322        //
323        // v1.4.104 codex round 1 F3 (P2) fix: 同时 capture caller's KeyRecord
324        // snapshot (Option<Arc<KeyRecord>>), 后续放进 CallerSnapshot 让 call
325        // sites 用同一身份做 response filter / push subscriber ownership /
326        // visibility — 不再 re-resolve from Bearer/startup (TOCTOU + drift risk).
327        let resolved_rec: Option<std::sync::Arc<futu_auth::KeyRecord>> = match plaintext_override {
328            Some(p) => match self.state.key_store().verify(p) {
329                Some(rec) => Some(rec),
330                None => {
331                    audit_reject_with_context(
332                        &audit_ctx,
333                        operation,
334                        "<bearer-invalid>",
335                        "invalid HTTP Bearer / api_key — fail-closed (no fallback to startup key)",
336                    );
337                    return Err(serde_json::json!({
338                        "error": format!(
339                            "{operation}: invalid Bearer token / api_key argument. \
340                             v1.4.103 codex F4 fail-closed — daemon does NOT fall back \
341                             to startup key when caller-supplied auth fails verification."
342                        ),
343                        "status": "error",
344                    })
345                    .to_string());
346                }
347            },
348            // v1.4.106 codex 0608 F2 (P1): startup fallback 用
349            // `get_by_id_for_current_machine` 替代裸 `get_by_id`, 让 SIGHUP
350            // 收紧 allowed_machines 后能立即 reject (与 Bearer 路径 verify
351            // 自带 machine 校验行为对称).
352            None => self
353                .state
354                .authed_key()
355                .and_then(|k| self.state.key_store().get_by_id_for_current_machine(&k.id)),
356        };
357        let credential: Credential<'_> = match &resolved_rec {
358            Some(rec) => Credential::PreVerified(rec.clone()),
359            None => Credential::None,
360        };
361
362        if require_stable_identity && resolved_rec.is_none() {
363            audit_reject_with_context(
364                &audit_ctx,
365                operation,
366                "<missing-stable-identity>",
367                "modern push continuation requires a configured reusable identity",
368            );
369            return Err(serde_json::json!({
370                "error": format!("{operation}: reusable API key identity required for modern push continuation"),
371                "status": "error",
372            })
373            .to_string());
374        }
375
376        // Pipeline: scope check + expiry + acc_id 白名单 + audit emit 一处.
377        // - explicit_acc_id: MCP tool args 直接给 (跳 body decode, MCP 无 raw proto body)
378        // - commit_rate=false: read tool 不 commit rate (rate gate 是 trade write 专属)
379        let env = AuthEnvelope {
380            surface: SurfaceId::Mcp,
381            endpoint: Endpoint::McpTool(operation),
382            needed_scope: Some(needed_scope),
383            credential,
384            proto_id: None,
385            body: &[],
386            explicit_acc_id: acc_id,
387            explicit_ctx: None,
388            commit_rate: false,
389            audit_emit: true,
390        };
391
392        match futu_auth::audit::with_context(audit_ctx.clone(), || {
393            authenticate_request(self.state.key_store(), self.state.counters(), env)
394        }) {
395            AuthDecision::Allow {
396                allowed_acc_ids, ..
397            } => {
398                // v1.4.104 codex F3 (P2): 返 caller snapshot 让 call sites
399                // 用同一身份做 response filter / push ownership.
400                Ok(CallerSnapshot {
401                    key_id: resolved_rec.as_ref().map(|r| r.id.clone()),
402                    rec: resolved_rec,
403                    allowed_acc_ids,
404                })
405            }
406            AuthDecision::Reject {
407                kind,
408                reason,
409                audit_key_id,
410            } => {
411                // pipeline 已 audit reject; v1.4.106 D1 5d: 走 rich-context
412                // helper, 翻成 MCP-specific JSON.
413                Err(Self::mcp_reject_to_json(
414                    kind,
415                    reason,
416                    operation,
417                    &audit_key_id,
418                ))
419            }
420        }
421    }
422
423    /// 交易写守卫;ctx=Some 时同时做限额检查;override_key=Some 时优先用该 plaintext.
424    ///
425    /// ## v1.4.104 阶段 7-4: pipeline 委托
426    ///
427    /// 把 `guard::require_trading` 165 LoC 折叠为 ~70 LoC 调用 pipeline:
428    /// - **legacy 2 级开关 (`enable_trading` / `allow_real_trading`) 仍在本地**
429    ///   (MCP-specific, pipeline 不知 daemon 启动 flag).
430    /// - per-call `override_key` 仍在本地 verify (保留 v1.4.103 codex F4
431    ///   verbose error message: "per-call api_key invalid").
432    /// - **scope check + expiry + rate gate + body-aware ctx + audit** 全
433    ///   委托 `authenticate_request` (与 4 surface unified).
434    pub(crate) fn require_trading(
435        &self,
436        tool: &'static str,
437        env: &str,
438        ctx: Option<CheckCtx>,
439        override_key: Option<&str>,
440    ) -> Option<String> {
441        use futu_auth_pipeline::{
442            AuthDecision, AuthEnvelope, Credential, Endpoint, RejectKind, SurfaceId,
443            authenticate_request,
444        };
445
446        let is_real = handlers::trade_write::is_real_env(env);
447        let needed_scope = futu_auth::trade_scope_for_env_is_real(is_real);
448
449        // ── Legacy 2 级开关 (MCP-specific, 不进 pipeline) ────────────────────────
450        if !self.state.is_scope_mode() {
451            if !self.state.enable_trading() {
452                futu_auth::audit::reject("mcp", tool, "<legacy>", "legacy: --enable-trading off");
453                return Some(
454                    serde_json::json!({
455                        "error": "trading tools are disabled. Start futu-mcp with --enable-trading to enable.",
456                        "status": "error",
457                    })
458                    .to_string(),
459                );
460            }
461            if is_real && !self.state.allow_real_trading() {
462                futu_auth::audit::reject(
463                    "mcp",
464                    tool,
465                    "<legacy>",
466                    "legacy: real env but --allow-real-trading off",
467                );
468                return Some(
469                    serde_json::json!({
470                        "error": "real trading is not allowed. Use env=\"simulate\" or restart futu-mcp with --allow-real-trading.",
471                        "status": "error",
472                    })
473                    .to_string(),
474                );
475            }
476            futu_auth::audit::allow("mcp", tool, "<legacy>", Some("legacy trading allowed"));
477            return None;
478        }
479
480        // ── Resolve credential (per-call override or startup) ─────────────────────
481        // per-call override 失败 → MCP-specific verbose reject (与 v1.4.103 兼容).
482        let credential: Credential<'_> = if let Some(plaintext) =
483            override_key.filter(|p| !p.is_empty())
484        {
485            match self.state.key_store().verify(plaintext) {
486                Some(rec) => Credential::PreVerified(rec),
487                None => {
488                    futu_auth::audit::reject(
489                        "mcp",
490                        tool,
491                        "<override-invalid>",
492                        "per-call api_key invalid",
493                    );
494                    return Some(
495                        serde_json::json!({
496                            "error": "per-call api_key is invalid (not in keys.json or expired/bound to wrong machine)",
497                            "status": "error",
498                        })
499                        .to_string(),
500                    );
501                }
502            }
503        } else {
504            let startup = self.state.authed_key();
505            if let Some(startup) = startup.as_ref() {
506                // SIGHUP-aware fresh lookup + machine binding 校验
507                // v1.4.106 codex 0608 F2 (P1): get_by_id_for_current_machine 替代裸
508                // get_by_id, machine binding 失败也按 "key revoked" 处理.
509                match self
510                    .state
511                    .key_store()
512                    .get_by_id_for_current_machine(&startup.id)
513                {
514                    Some(rec) => Credential::PreVerified(rec),
515                    None => {
516                        futu_auth::audit::reject("mcp", tool, &startup.id, "key revoked");
517                        return Some(
518                            serde_json::json!({
519                                "error": format!("API key {:?} has been revoked", startup.id),
520                                "status": "error",
521                            })
522                            .to_string(),
523                        );
524                    }
525                }
526            } else {
527                futu_auth::audit::reject("mcp", tool, "<none>", "no API key");
528                return Some(
529                    serde_json::json!({
530                        "error": "API key required for trading tools (set FUTU_MCP_API_KEY, or pass api_key in the tool call)",
531                        "status": "error",
532                    })
533                    .to_string(),
534                );
535            }
536        };
537
538        // ── Pipeline: scope check + expiry + rate gate (commit) + ctx-aware ──────
539        // commit_rate=true: trade write 是 MCP rate gate (与 v1.4.103
540        // `state.counters.check_and_commit(ctx, ...)` 行为对齐, 模拟 + 真单都
541        // commit rate, 防 simulate flood backend).
542        // explicit_ctx: 全 ctx 走 body-aware loop (market/symbol/value/side/acc_id 全检查).
543        let env_envelope = AuthEnvelope {
544            surface: SurfaceId::Mcp,
545            endpoint: Endpoint::McpTool(tool),
546            needed_scope: Some(needed_scope),
547            credential,
548            proto_id: None,
549            body: &[],
550            explicit_acc_id: None,
551            explicit_ctx: ctx.clone(),
552            commit_rate: true,
553            audit_emit: true,
554        };
555
556        match authenticate_request(self.state.key_store(), self.state.counters(), env_envelope) {
557            AuthDecision::Allow { .. } => None,
558            AuthDecision::Reject {
559                kind,
560                reason,
561                audit_key_id,
562            } => {
563                // v1.4.106 D1 5d: 走 rich-context helper.
564                // **行为差异 (intentional)**: trade 路径 Unauthenticated 文案
565                // 与 require_acc_read_with_acc_id 不同 — read 路径强调"提供 key",
566                // trade 路径强调"key expired/revoked" (caller 已知有 key 但 verify
567                // 后 expired/revoked, e.g. SIGHUP reload 后 key 失效).
568                // 这里 inline match 保留, 不进 mcp_reject_to_json.
569                let prefix = match kind {
570                    RejectKind::Unauthenticated => {
571                        format!("API key {audit_key_id:?} expired or revoked: {reason}")
572                    }
573                    RejectKind::Forbidden => {
574                        format!("API key {audit_key_id:?} forbidden: {reason}")
575                    }
576                    RejectKind::RateLimited => format!("rate limit: {reason}"),
577                    _ => reason.clone(),
578                };
579                Some(
580                    serde_json::json!({
581                        "error": prefix,
582                        "status": "error",
583                    })
584                    .to_string(),
585                )
586            }
587        }
588    }
589
590    // v1.4.106 codex round 1 F4 (P2): `current_key_id(&self, Option<&str>)`
591    // 已废弃删除. 之前 5 处 emit_trade_outcome 用它做 daemon dispatch 后的
592    // audit attribution, 但 SIGHUP reload 在 dispatch 中途 revoke caller 的
593    // key 时, 该 helper 会 silent fallback 到 startup key → audit 记录被错
594    // 归属. 现统一用 [`outcome_key_id_from_snapshot`] 取 precheck 时的
595    // snapshot — race-free.
596    //
597    // 历史调用点 (全部已迁移):
598    // - futu_place_order / futu_modify_order / futu_cancel_order /
599    //   futu_reconfirm_order / futu_cancel_all_order / futu_unlock_trade
600    //   (6 处 emit_trade_outcome)
601    //
602    // 没有其他 surface caller (grep 全 workspace 已确认).
603
604    /// v1.4.105 D12 contract-hardening 补丁: 拿当前 caller 的 KeyRecord (per-call key
605    /// 优先 > startup key). legacy mode (无 keys.json) 返 None.
606    /// 用于 trade tool 调 resolve_acc_id_with_card_num 时获取
607    /// `allowed_card_nums` 做 string-level whitelist 校验.
608    /// codex round 1 F2 (P2) v1.4.105 移除老的 `current_key_rec` —
609    /// 改用下面 `require_caller_key_strict` (fail-closed). 移除原因: invalid
610    /// override 时 silent fallback startup → 给 backend resolve_acc_id_with_card_num
611    /// 探测 leak. legacy mode 仍由 strict helper Ok(None) 返回处理.
612    ///
613    /// codex round 1 F2 (P2) v1.4.105: 在 trade write 路径里**先**验证 caller
614    /// key + fail-closed, **再** resolve_acc_id_with_card_num. 防 invalid
615    /// Bearer 仍触发 daemon GetAccList + 用 startup key 的 `allowed_card_nums`
616    /// 做 resolution (探测 leakage).
617    ///
618    /// 与 `current_key_rec` 区别:
619    /// - `current_key_rec`: invalid override → silent fallback startup key →
620    ///   resolve 已 side-effect.
621    /// - **`require_caller_key_strict`**: invalid override → 立即 Err 返 reject
622    ///   JSON, **绝不 fallback**. 也保护 legacy mode (返 Ok(None)) 不破.
623    ///
624    /// Return:
625    /// - `Ok(Some(rec))`: caller key 已验, 用其 `allowed_card_nums` 做 resolve
626    /// - `Ok(None)`: scope mode 关闭 (legacy), require_trading 后续会处理
627    /// - `Err(json_str)`: invalid override / no key / startup key revoked,
628    ///   立即 abort
629    pub(crate) fn require_caller_key_strict(
630        &self,
631        tool: &'static str,
632        override_key: Option<&str>,
633    ) -> std::result::Result<Option<std::sync::Arc<futu_auth::KeyRecord>>, String> {
634        // legacy 模式 (无 keys.json) 不强制 — 后续 require_trading 仍会过 legacy
635        // toggle, 此处放行 (返 Ok(None)) 保持向后兼容
636        if !self.state.is_scope_mode() {
637            return Ok(None);
638        }
639
640        if let Some(pt) = override_key.filter(|p| !p.is_empty()) {
641            // 显式传 override_key → 验证, 无 fallback 防 leak
642            match self.state.key_store().verify(pt) {
643                Some(rec) => Ok(Some(rec)),
644                None => {
645                    futu_auth::audit::reject(
646                        "mcp",
647                        tool,
648                        "<override-invalid>",
649                        "per-call api_key invalid (pre-resolve fail-closed)",
650                    );
651                    Err(serde_json::json!({
652                        "error": "per-call api_key is invalid (not in keys.json or expired/bound to wrong machine)",
653                        "status": "error",
654                    })
655                    .to_string())
656                }
657            }
658        } else {
659            let startup = self.state.authed_key();
660            if let Some(startup) = startup.as_ref() {
661                // 无 override → 用 startup key (SIGHUP-aware fresh lookup + machine 校验)
662                // v1.4.106 codex 0608 F2 (P1): get_by_id_for_current_machine 替代裸
663                // get_by_id, machine 失败 / id 失踪都按 "key revoked" 处理.
664                match self
665                    .state
666                    .key_store()
667                    .get_by_id_for_current_machine(&startup.id)
668                {
669                    Some(rec) => Ok(Some(rec)),
670                    None => {
671                        futu_auth::audit::reject(
672                            "mcp",
673                            tool,
674                            &startup.id,
675                            "key revoked (pre-resolve fail-closed)",
676                        );
677                        Err(serde_json::json!({
678                            "error": format!("API key {:?} has been revoked", startup.id),
679                            "status": "error",
680                        })
681                        .to_string())
682                    }
683                }
684            } else {
685                // scope 模式但无 startup key 也无 override → 立即 reject
686                futu_auth::audit::reject(
687                    "mcp",
688                    tool,
689                    "<none>",
690                    "no API key (pre-resolve fail-closed)",
691                );
692                Err(serde_json::json!({
693                    "error": "API key required for trading tools (set FUTU_MCP_API_KEY, or pass api_key in the tool call)",
694                    "status": "error",
695                })
696                .to_string())
697            }
698        }
699    }
700
701    /// codex round 2 F1 (P2) v1.4.105: trade write 路径 **早期 trade-scope
702    /// 校验** — 在 `client_or_err` + `resolve_acc_id_with_card_num` 之前.
703    ///
704    /// 与 `require_trading` (full ctx body-aware) 区别:
705    /// - `require_trading`: 完整 scope + acc_id whitelist + market/symbol/value
706    ///   + rate gate (最终 gate, 在 resolve 之后).
707    /// - **`require_trading_scope_only`**: 只 verify caller key 含 `trade:real`
708    ///   或 `trade:simulate` scope. **不**检查 acc_id / market / symbol /
709    ///   value (这些 final gate 仍由 `require_trading` 后做).
710    ///
711    /// **目标**: 防 valid 但**非-trade key** (e.g. `qot:read` only) 触发 daemon
712    /// `GetAccList` + `card_num` resolution → 探测 not-found / ambiguous /
713    /// existence timing & messages, 之后才被 final `require_trading` 拒绝.
714    /// 早期 scope check 让此类 key 在 resolve 之前 fail-closed.
715    ///
716    /// **不替代** `require_trading` — 只前置一个轻量 scope guard. 后续 final
717    /// gate (含 ctx) 仍跑.
718    pub(crate) fn require_trading_scope_only(
719        &self,
720        tool: &'static str,
721        env: &str,
722        caller_key_rec: Option<&std::sync::Arc<futu_auth::KeyRecord>>,
723    ) -> Option<String> {
724        match decide_early_trade_scope(env, self.state.is_scope_mode(), caller_key_rec) {
725            EarlyTradeScopeDecision::Allow => None,
726            EarlyTradeScopeDecision::RejectMissingCallerKey => {
727                futu_auth::audit::reject(
728                    "mcp",
729                    tool,
730                    "<no-caller-key>",
731                    "early-trade-scope: caller key snapshot missing (defensive)",
732                );
733                Some(
734                    serde_json::json!({
735                        "error": "internal: caller key missing for early trade-scope check",
736                        "status": "error",
737                    })
738                    .to_string(),
739                )
740            }
741            EarlyTradeScopeDecision::RejectMissingScope { needed, key_id } => {
742                futu_auth::audit::reject(
743                    "mcp",
744                    tool,
745                    &key_id,
746                    &format!("early-trade-scope: missing {needed:?} — pre-resolve fail-closed"),
747                );
748                Some(
749                    serde_json::json!({
750                        "error": format!(
751                            "API key {:?} forbidden — needs {} scope",
752                            key_id, scope_label(needed)
753                        ),
754                        "status": "error",
755                    })
756                    .to_string(),
757                )
758            }
759        }
760    }
761}
762
763#[cfg(test)]
764mod tests;