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