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