Skip to main content

vigil_firewall/
engine.rs

1//! Firewall 高层缝合:extract → score → policy → 产出 `FirewallOutcome`。
2
3use std::collections::HashMap;
4use std::path::PathBuf;
5use std::sync::Arc;
6
7use sha2::{Digest, Sha256};
8use thiserror::Error;
9use uuid::Uuid;
10use vigil_audit::{ApprovalTargetContext, EngineDegradedPayload, Ledger, Result as AuditResult};
11use vigil_policy::{
12    DescriptorState, PolicyAction, PolicyContext, PolicyDecision, PolicyEngine, PolicyError,
13};
14use vigil_types::{ApprovalRequest, DecisionKind, DecisionRecord, EffectVector, ToolInvocation};
15
16use crate::extract::{
17    BrowserActionExtractor, EffectExtractor, EmailExtractor, PathExtractor, SecretRefExtractor,
18    ShellExtractor, SqlExtractor, UrlExtractor,
19};
20use crate::preflight::{run_preflight, EngineStatusReport, PreflightError};
21use crate::scorer::{DescriptorOracle, DescriptorStatus, RiskScorer};
22
23/// Firewall 错误。
24#[derive(Debug, Error)]
25#[non_exhaustive]
26pub enum FirewallError {
27    /// 策略引擎错误。
28    #[error("policy: {0}")]
29    Policy(#[from] PolicyError),
30
31    /// 审计写入错误。
32    #[error("audit: {0}")]
33    Audit(#[from] vigil_audit::AuditError),
34
35    /// I10c-β2 R3 NICE 修复:`FirewallConfig.allowed_scopes` 使用了保留键
36    /// `"allowed_hosts"`,会在 `evaluate` 合并步骤覆盖 host allowlist —— 启动期
37    /// 硬拒绝,避免误配置破坏 host 白名单语义。
38    #[error(
39        "config: `allowed_scopes` must not reuse reserved key `allowed_hosts` \
40         (host allowlist is managed via `FirewallConfig::allowed_hosts`)"
41    )]
42    ReservedScopeKey,
43
44    /// ISS-010:T0 preflight 扫描(`vigil_redaction::scan_text`)返错。
45    ///
46    /// **语义**:安全核心的 fail-closed 路径 —— preflight 是在规则决策**之前**运行的,
47    /// 扫描失败意味着我们无法判断本次调用是否带 PII / Secret,必须视为最坏情况。caller
48    /// 应把此错误翻译成业务层 Deny(不继续走 policy 评估,也不入 approvals 表)。
49    ///
50    /// `reason` 由 `ScanError::{InferenceFailed, ..}` 的 Debug 形式派生,不含用户原文。
51    #[error("preflight scan failed: {reason}")]
52    PreflightScanFailed {
53        /// 来自 `vigil_redaction::ScanError` 的 Debug 投影(无用户原文)。
54        reason: String,
55    },
56}
57
58/// Firewall 配置:项目根 / 允许主机 / OAuth scope allowlist / TTL 等。
59#[derive(Debug, Clone)]
60pub struct FirewallConfig {
61    /// POSIX 规范化的项目根目录前缀
62    pub project_roots: Vec<String>,
63    /// 允许的主机列表(支持 `.github.com` 风格的后缀模式由 policy 的
64    /// `host_matches` 实现;Firewall 这层只作为 RiskScorer 与 PolicyContext 的输入)
65    pub allowed_hosts: Vec<String>,
66    /// I10c-β2(R3 BLOCKER 修复):OAuth scope allowlist 注入通道。
67    ///
68    /// 键是 `Condition::ScopeNotInAllowList::allowlist_key` 引用的逻辑名
69    /// (如 `"oauth_scopes"` / `"github_scopes"` / `"gitlab_scopes"`),值是该 AS
70    /// 允许的 scope 白名单。Firewall 在评估前把 entry 合并到 `PolicyContext.allowlists`,
71    /// 与 `allowed_hosts`(固定键 `"allowed_hosts"`)并列。
72    ///
73    /// **命名隔离约定**:请勿在此 map 里使用键 `"allowed_hosts"`,避免与 host allowlist
74    /// 冲突;Firewall 不做 runtime 检查(类型上共享 `HashMap<String, Vec<String>>`),
75    /// 配置加载层自行保证键不相撞。
76    pub allowed_scopes: HashMap<String, Vec<String>>,
77    /// 审批 TTL 秒。默认 300(5 分钟)。`0` 表示立即过期(供测试)。
78    pub approval_ttl_secs: u64,
79
80    /// ISS-010:T0 preflight 扫描的长文本阈值(字节)。
81    ///
82    /// `Firewall::evaluate` 递归 `ToolInvocation.args` 里的所有字符串字段,长度 `≥`
83    /// 此阈值的才送进 `vigil_redaction::scan_text`。默认 `100`(覆盖典型提示词 / 邮件
84    /// 正文 / SQL 大段,放过短工具参数如 `"path": "/etc/hosts"`)。
85    ///
86    /// **边界**:本阈值以 `str::len()`(UTF-8 bytes)为准,而非字符数;ASCII 场景下
87    /// 等同于 char count。取 `0` 等同 "扫所有字符串"(含空串 —— 但空串在 scan_text 层
88    /// 会被当 EmptyInput continue,不会误触 fail-closed)。
89    pub long_text_threshold: usize,
90}
91
92impl Default for FirewallConfig {
93    fn default() -> Self {
94        Self {
95            project_roots: Vec::new(),
96            allowed_hosts: Vec::new(),
97            allowed_scopes: HashMap::new(),
98            approval_ttl_secs: 300,
99            // ISS-010:典型 prompt / 粘贴板 / 邮件正文 ≥ 100 bytes,低于此的工具参数
100            // 走纯规则引擎,避免为每个 `{"path": "x"}` 调用都跑 regex 扫。
101            long_text_threshold: 100,
102        }
103    }
104}
105
106/// I10c-β2(R3 MUST-FIX 修复):调用路径的 OAuth 上下文,显式区分"非 OAuth"与"OAuth + scope"。
107///
108/// 出现在 [`Firewall::evaluate`] 签名里作为**必填参数**,强制调用方每次调用都明确选择,
109/// 防止 HTTP MCP 集成点意外漏配 scope 导致静默绕过。
110///
111/// - [`OAuthScopeContext::NonOauth`] —— stdio MCP / 本地工具 / 不走 OAuth 的任何路径
112/// - [`OAuthScopeContext::Scopes`] —— HTTP MCP + OAuth access token,scope 来自
113///   `vigil_http_auth::ResolvedAccessToken::scope_set`(空集也必须显式 `Scopes(vec![])`,
114///   触发 `ScopeNotInAllowList` 的 fail-closed 分支)
115#[derive(Debug, Clone, PartialEq, Eq)]
116#[non_exhaustive]
117pub enum OAuthScopeContext {
118    /// 非 OAuth 路径,`ScopeNotInAllowList` 不适用
119    NonOauth,
120    /// OAuth 路径 + token 携带的 scope 集合(可空 → fail-closed)
121    Scopes(Vec<String>),
122}
123
124impl OAuthScopeContext {
125    fn into_policy_requested_scopes(self) -> Option<Vec<String>> {
126        match self {
127            OAuthScopeContext::NonOauth => None,
128            OAuthScopeContext::Scopes(s) => Some(s),
129        }
130    }
131}
132
133/// Firewall.evaluate() 返回的裁决结果。
134///
135/// caller(I04 MCP Hub)根据此结果决定:
136/// - `Allowed`: 直接执行下游
137/// - `Denied`: 对 agent 返回安全错误
138/// - `Approve`: 创建 approval 并阻塞 / 异步等待 wait_for_resolution
139#[derive(Debug, Clone)]
140#[non_exhaustive]
141pub enum FirewallOutcome {
142    /// 放行。
143    Allowed {
144        /// 决策记录(已写入账本)
145        decision: DecisionRecord,
146        /// 推断出的 effects
147        effects: EffectVector,
148    },
149    /// 拒绝。
150    Denied {
151        /// 决策记录(已写入账本)
152        decision: DecisionRecord,
153        /// 推断出的 effects
154        effects: EffectVector,
155    },
156    /// 需要审批。
157    Approve {
158        /// 决策记录(已写入账本)
159        decision: DecisionRecord,
160        /// 推断出的 effects
161        effects: EffectVector,
162        /// 待审批请求(已入 `approvals` 表)
163        approval: ApprovalRequest,
164    },
165}
166
167impl FirewallOutcome {
168    /// 快捷:返回底层 DecisionKind。
169    pub fn decision_kind(&self) -> DecisionKind {
170        match self {
171            FirewallOutcome::Allowed { .. } => DecisionKind::Allow,
172            FirewallOutcome::Denied { .. } => DecisionKind::Deny,
173            FirewallOutcome::Approve { .. } => DecisionKind::Approve,
174        }
175    }
176}
177
178/// Firewall 主组件。持有 extractors / scorer / policy 引擎 / 审计账本 / PII scanner。
179pub struct Firewall {
180    ledger: Arc<Ledger>,
181    policy: PolicyEngine,
182    scorer: RiskScorer,
183    extractors: Vec<Box<dyn EffectExtractor>>,
184    config: FirewallConfig,
185    /// ISS-010 R2:PII preflight scanner,默认 `DefaultScanner`(forward 到
186    /// `vigil_redaction::scan_text`);测试可通过 `with_scanner` 注入。
187    scanner: Arc<dyn crate::preflight::PiiScanner>,
188    /// ISS-010 R2 MUST-FIX 2:preflight audit 写失败累计(无原文;不 stderr 污染)。
189    audit_persist_failures: Arc<crate::preflight::AuditPersistCounter>,
190}
191
192impl std::fmt::Debug for Firewall {
193    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
194        f.debug_struct("Firewall")
195            .field("policy_rule_count", &self.policy.len())
196            .field("extractor_count", &self.extractors.len())
197            .field("config", &self.config)
198            .field(
199                "audit_persist_failures",
200                &self
201                    .audit_persist_failures
202                    .load(std::sync::atomic::Ordering::Relaxed),
203            )
204            .finish()
205    }
206}
207
208impl Firewall {
209    /// 组装一个 Firewall:内置 7 个 extractor + 提供的 policy + scorer + 默认
210    /// `DefaultScanner`(见 [`Firewall::with_scanner`] 注入自定义)。
211    pub fn new(ledger: Arc<Ledger>, policy: PolicyEngine, config: FirewallConfig) -> Self {
212        Self::with_scanner(
213            ledger,
214            policy,
215            config,
216            crate::preflight::default_scanner_arc(),
217        )
218    }
219
220    /// **ISS-010 R2 BLOCKER 2 修复**:同 `new`,但接受自定义 `scanner`,主要供测试注入
221    /// `FailingScanner` 真触发 fail-closed 路径(见 tests/preflight.rs)。
222    pub fn with_scanner(
223        ledger: Arc<Ledger>,
224        policy: PolicyEngine,
225        config: FirewallConfig,
226        scanner: Arc<dyn crate::preflight::PiiScanner>,
227    ) -> Self {
228        let roots: Vec<PathBuf> = config.project_roots.iter().map(PathBuf::from).collect();
229        let scorer = RiskScorer::new(config.allowed_hosts.clone(), config.project_roots.clone());
230        let extractors: Vec<Box<dyn EffectExtractor>> = vec![
231            Box::new(PathExtractor::new(roots)),
232            Box::new(UrlExtractor),
233            Box::new(SqlExtractor),
234            Box::new(ShellExtractor),
235            Box::new(EmailExtractor),
236            Box::new(SecretRefExtractor),
237            Box::new(BrowserActionExtractor),
238        ];
239        Self {
240            ledger,
241            policy,
242            scorer,
243            extractors,
244            config,
245            scanner,
246            audit_persist_failures: Arc::new(crate::preflight::AuditPersistCounter::new(0)),
247        }
248    }
249
250    /// 返回 preflight audit 写失败累计(进程生命周期内)。0 = 一切正常。
251    ///
252    /// **R2 MUST-FIX 2**:替代旧的 `eprintln!` 观测通道。测试可用以验证
253    /// audit 是否静默降级。
254    pub fn audit_persist_failures(&self) -> u64 {
255        self.audit_persist_failures
256            .load(std::sync::atomic::Ordering::Relaxed)
257    }
258
259    /// 评估一次 tool call。I10c-β2 R3 统一签名:OAuth 上下文由 [`OAuthScopeContext`]
260    /// **必填参数**显式传入,防止 HTTP MCP 集成点漏配 scope 导致静默绕过。
261    ///
262    /// - 本地工具 / stdio MCP:传 [`OAuthScopeContext::NonOauth`]
263    /// - HTTP MCP + OAuth access token:传 [`OAuthScopeContext::Scopes`]
264    ///   (scope 集合来自 `vigil_http_auth::ResolvedAccessToken::scope_set`;
265    ///   空 scope 也必须显式 `Scopes(vec![])`,触发 fail-closed)
266    ///
267    /// 步骤(ADR 0003 §D3 + 方案 §3.3 + ADR 0004 §D8):
268    /// 1. 所有 extractor 合并产出 `EffectVector`
269    /// 2. 通过 [`DescriptorOracle`] 查询 descriptor 当前信任状态
270    /// 3. `RiskScorer` 打分 + reasons
271    /// 4. `PolicyEngine` 按规则评估,获得 `PolicyDecision`
272    ///    (`FirewallConfig::allowed_scopes` 自动合并到 `PolicyContext.allowlists`)
273    /// 5. 组装 `DecisionRecord`,调用 `Ledger::record_decision` 入账
274    /// 6. 若 Approve,`create_approval` 入 approvals 表(带 server/tool/args_hash 上下文)
275    pub fn evaluate(
276        &self,
277        call: &ToolInvocation,
278        oracle: &dyn DescriptorOracle,
279        scope_ctx: OAuthScopeContext,
280    ) -> Result<FirewallOutcome, FirewallError> {
281        // 0) **R2 R1 新发现 3 修复** —— reserved-key guard 在 preflight **之前**。
282        //    配置错误时不应先扫描并落 redaction 审计副作用。
283        //
284        // VIGIL-SEC-005(security audit):allowed_scopes 与 allowed_hosts 共用 ctx.allowlists
285        // 同一 map,后写覆盖。用**保留键集合**守门(而非单一字面量),未来引擎新增固定 allowlist
286        // 键时只需扩 RESERVED_ALLOWLIST_KEYS,不会因约定遗忘而被 allowed_scopes 静默覆盖。
287        const RESERVED_ALLOWLIST_KEYS: &[&str] = &["allowed_hosts"];
288        if self
289            .config
290            .allowed_scopes
291            .keys()
292            .any(|k| RESERVED_ALLOWLIST_KEYS.contains(&k.as_str()))
293        {
294            return Err(FirewallError::ReservedScopeKey);
295        }
296
297        // 1) extract
298        let mut effects = EffectVector::default();
299        for ex in &self.extractors {
300            ex.extract(call, &mut effects);
301        }
302        dedup_effects(&mut effects);
303
304        // 2) oracle(权威来源)
305        let descriptor = oracle.status(&call.server_id, &call.tool_name, &call.descriptor_hash);
306
307        // 3) score
308        let (risk_score, score_reasons) = self.scorer.score(&effects, descriptor);
309
310        // 3b) ISS-010 preflight —— T0 redaction scan 产 PII findings + risk delta。
311        //
312        // 纪律:
313        // - 扫 `call.args` 里所有 ≥ `long_text_threshold` 的字符串字段(递归 Value)
314        // - `scan_text` 返 Err(非 EmptyInput)→ fail-closed `FirewallError::PreflightScanFailed`
315        // - findings 聚合成 `PiiFindingSummary` 喂 `PolicyContext.pii_findings`,
316        //   让规则层 `Condition::PiiContains` 消费
317        // - risk_delta 按 ADR 0012 §1.3 已在 redaction 层累加,这里 saturating_add 后
318        //   clamp 到 100(PolicyContext.risk_score 是 u8)
319        let preflight = run_preflight(
320            self.scanner.as_ref(),
321            &self.ledger,
322            &self.audit_persist_failures,
323            &call.session_id,
324            &call.args,
325            self.config.long_text_threshold,
326        )
327        .map_err(|e| match e {
328            PreflightError::ScanFailed { reason } => FirewallError::PreflightScanFailed { reason },
329        })?;
330
331        // 叠加:risk_score(u8) + preflight.risk_delta(u32),先升到 u32 饱和加,再 clamp 到 100
332        let base_risk = risk_score;
333        let pii_delta = preflight.risk_delta;
334        let risk_with_pii = (base_risk as u32).saturating_add(pii_delta).min(100) as u8;
335
336        // 3) policy —— 把 descriptor 状态透传给引擎,让 drift/first-seen 进规则体系。
337        // 本 crate 与 DescriptorStatus 同源,AGENTS.md non_exhaustive 纪律要求写 `_`,
338        // 编译器会把它标为 unreachable;接受这一警告由 #[allow] 局部消音。
339        #[allow(unreachable_patterns)]
340        let descriptor_state = match descriptor {
341            DescriptorStatus::ApprovedStable => DescriptorState::ApprovedStable,
342            DescriptorStatus::FirstSeen => DescriptorState::FirstSeen,
343            DescriptorStatus::Drifted => DescriptorState::Drifted,
344            // non_exhaustive fail-closed:未知扩展视为 FirstSeen 升级严厉度
345            _ => DescriptorState::FirstSeen,
346        };
347        // reserved-key guard 已在 evaluate 第 0 步前置(R2 R1 新发现 3 修复)。
348        // 之前此处的重复 guard 已删除。
349
350        let mut ctx = PolicyContext {
351            // ISS-010:policy 看到的 risk_score 是 PII 加权后的最终值;基础分
352            // + PII delta 分别在 DecisionRecord.reasons 里留痕(便于审计溯源)。
353            risk_score: risk_with_pii,
354            descriptor: descriptor_state,
355            // I10c-β2:OAuth 上下文由签名上的 `scope_ctx` 显式传入,转换为
356            // `PolicyContext::requested_scopes` 三态(见其文档)。
357            requested_scopes: scope_ctx.into_policy_requested_scopes(),
358            // ISS-010:T0 redaction preflight 聚合后的 PII 摘要
359            pii_findings: preflight.pii_summary.clone(),
360            ..Default::default()
361        };
362        ctx.roots
363            .insert("project_roots".into(), self.config.project_roots.clone());
364        // host allowlist 用固定键 `allowed_hosts`。
365        ctx.allowlists
366            .insert("allowed_hosts".into(), self.config.allowed_hosts.clone());
367        // I10c-β2 R3:OAuth scope allowlist 由 config 驱动合并。键由 caller 在
368        // `FirewallConfig::allowed_scopes` 里自行命名(典型:`oauth_scopes` /
369        // `github_scopes`);禁止与 `allowed_hosts` 相撞 —— 已在上方 reserved-key
370        // guard 兜底,此处只做合并。
371        for (k, v) in &self.config.allowed_scopes {
372            ctx.allowlists.insert(k.clone(), v.clone());
373        }
374        let pdec: PolicyDecision = self.policy.evaluate(&effects, &ctx)?;
375
376        // 4) decision —— risk_score 记 PII 加权后的最终值(policy 实际看到的);
377        //    reasons 在 scorer reasons + policy reasons 之外,**额外追加 preflight 摘要**
378        //    (R2 MUST-FIX 1 修复):`preflight: base_risk=X pii_delta=Y final=Z labels=<label=count,...>`
379        //    让审计员能从单条 DecisionRecord 看出"底分 vs. PII 叠加"的完整拆分,不再需
380        //    要去交叉查 redaction_scans / redaction_findings。
381        let preflight_reason = format!(
382            "preflight: base_risk={} pii_delta={} final={} labels={}",
383            base_risk,
384            pii_delta,
385            risk_with_pii,
386            if preflight.pii_summary.is_empty() {
387                "(none)".to_string()
388            } else {
389                preflight.counts_csv()
390            }
391        );
392        let mut decision_reasons = merge_reasons(&score_reasons, &pdec.reasons);
393        decision_reasons.push(preflight_reason);
394
395        // v0.8 Sprint 1 A2 — scanner 退化感知:若 preflight 期间任一文本走退化路径
396        // (DegradedTimeout / DegradedError),把 stable code 推进 reasons 留痕。
397        // `Ok` / `Unsupported` 不写 reasons(无新信息;Unsupported 是 trait default,
398        // 表示 scanner 实现不上报状态,caller 维持原决策路径)。
399        // crate 内部穷举 EngineStatusReport(定义在 vigil-firewall::preflight);加新
400        // variant 时 compiler force update 本 match。外部 consumer 因 #[non_exhaustive]
401        // 必须写 `_` 兜底,内部代码无需。新 variant 的 fail-closed 决策由 author 在加
402        // variant 时显式选边(归 Degraded 类记 reasons / 归 Ok 类 None / 单独路径)。
403        let degraded_status = match preflight.engine_status {
404            EngineStatusReport::DegradedTimeout | EngineStatusReport::DegradedError => {
405                let stable = preflight.engine_status.stable_code();
406                decision_reasons.push(format!("engine.status={stable}"));
407                Some(preflight.engine_status)
408            }
409            EngineStatusReport::Ok | EngineStatusReport::Unsupported => None,
410        };
411
412        // decision_id 提前生成:engine_degraded payload 需要它做 audit 跨表 join。
413        let decision_id = Uuid::new_v4().to_string();
414        let decision = DecisionRecord {
415            decision_id: decision_id.clone(),
416            invocation_id: call.invocation_id.clone(),
417            decision: map_action(pdec.action),
418            risk_score: risk_with_pii,
419            reasons: decision_reasons,
420            policy_ids: pdec.policy_ids.clone(),
421            created_at: now_secs(),
422        };
423        let _ = self
424            .ledger
425            .record_decision(&call.session_id, &decision, &effects)?;
426
427        // v0.8 Sprint 1 A2 — degraded 路径补落 audit `engine.degraded` 事件。
428        // 写在 record_decision 之后,确保 decision_id 已落库;写失败仅原子计数
429        // (`audit_persist_failures`),**不**阻断决策返回 —— 审计缺失不背锅当事。
430        //
431        // `engine_id` 暂用 stable string `"firewall_preflight_scanner"`:PiiScanner trait
432        // 抽象层不暴露具体 model_id,细分 engine_id 留 v0.8 Sprint 2(EnsembleEngine 内
433        // 单 model 退化标识)。budget_ms / elapsed_ms 同理留 Sprint 2(scan_with_status
434        // 当前不透出耗时,需扩展 trait 返 (RedactionResult, EngineStatusReport, Option<Duration>))。
435        if let Some(status) = degraded_status {
436            let payload = EngineDegradedPayload {
437                engine_id: "firewall_preflight_scanner".to_string(),
438                status: status.stable_code().to_string(),
439                reason_code: status.stable_code().to_string(),
440                budget_ms: None,
441                elapsed_ms: None,
442                fail_closed_decision: "fall_back_hard_only".to_string(),
443                decision_id: decision_id.clone(),
444            };
445            if self
446                .ledger
447                .record_engine_degraded(&call.session_id, &payload)
448                .is_err()
449            {
450                self.audit_persist_failures
451                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
452            }
453        }
454
455        // 5) 按 action 分支
456        match pdec.action {
457            PolicyAction::Allow => Ok(FirewallOutcome::Allowed { decision, effects }),
458            PolicyAction::Deny => Ok(FirewallOutcome::Denied { decision, effects }),
459            PolicyAction::Approve => {
460                let (title, summary) = summarize(call, &effects, &decision);
461                // args_hash:JCS 规范化后的 args SHA-256,用于 ThisSession scope 查询。
462                let args_hash = compute_args_hash(&call.args)?;
463                let ctx = ApprovalTargetContext {
464                    server_id: Some(&call.server_id),
465                    tool_name: Some(&call.tool_name),
466                    args_hash: Some(&args_hash),
467                };
468                let approval: AuditResult<ApprovalRequest> = self.ledger.create_approval(
469                    &call.session_id,
470                    &decision,
471                    &effects,
472                    &title,
473                    &summary,
474                    self.config.approval_ttl_secs,
475                    ctx,
476                );
477                let approval = approval?;
478                Ok(FirewallOutcome::Approve {
479                    decision,
480                    effects,
481                    approval,
482                })
483            }
484            // PolicyAction 是 non_exhaustive:未知扩展一律 fail-closed Deny(AGENTS.md)
485            _ => Ok(FirewallOutcome::Denied { decision, effects }),
486        }
487    }
488}
489
490fn dedup_effects(e: &mut EffectVector) {
491    // 去重但保留顺序
492    let mut seen = std::collections::HashSet::new();
493    e.effects.retain(|k| seen.insert(*k));
494    e.paths_read.sort();
495    e.paths_read.dedup();
496    e.paths_write.sort();
497    e.paths_write.dedup();
498    e.network_hosts.sort();
499    e.network_hosts.dedup();
500    e.secret_refs.sort();
501    e.secret_refs.dedup();
502    e.recipients.sort();
503    e.recipients.dedup();
504}
505
506fn map_action(a: PolicyAction) -> DecisionKind {
507    match a {
508        PolicyAction::Allow => DecisionKind::Allow,
509        PolicyAction::Deny => DecisionKind::Deny,
510        PolicyAction::Approve => DecisionKind::Approve,
511        // non_exhaustive:未知扩展映射为 Deny(fail-closed)
512        _ => DecisionKind::Deny,
513    }
514}
515
516fn merge_reasons(score: &[String], policy: &[String]) -> Vec<String> {
517    let mut out = Vec::with_capacity(score.len() + policy.len());
518    out.extend(score.iter().cloned());
519    out.extend(policy.iter().cloned());
520    out
521}
522
523fn summarize(
524    call: &ToolInvocation,
525    effects: &EffectVector,
526    dec: &DecisionRecord,
527) -> (String, String) {
528    let title = format!("{} on {}", call.tool_name, call.server_id);
529    let mut parts = Vec::new();
530    parts.push(format!("risk {}/100", dec.risk_score));
531    if !effects.paths_write.is_empty() {
532        parts.push(format!("writes: {}", effects.paths_write.join(", ")));
533    }
534    if !effects.paths_read.is_empty() {
535        parts.push(format!("reads: {}", effects.paths_read.len()));
536    }
537    if !effects.network_hosts.is_empty() {
538        parts.push(format!("hosts: {}", effects.network_hosts.join(", ")));
539    }
540    if !effects.secret_refs.is_empty() {
541        parts.push(format!("secrets: {}", effects.secret_refs.join(", ")));
542    }
543    if !effects.recipients.is_empty() {
544        parts.push(format!("recipients: {}", effects.recipients.len()));
545    }
546    (title, parts.join(" | "))
547}
548
549fn now_secs() -> i64 {
550    use std::time::{SystemTime, UNIX_EPOCH};
551    SystemTime::now()
552        .duration_since(UNIX_EPOCH)
553        .map(|d| d.as_secs() as i64)
554        .unwrap_or(0)
555}
556
557/// `args_hash` 计算:JCS 规范化后 SHA-256,十六进制小写。
558/// 与 approvals 表中的 args_hash 列语义一致;I04 ThisSession scope 查询要求相等。
559pub(crate) fn compute_args_hash(args: &serde_json::Value) -> Result<String, FirewallError> {
560    let bytes = serde_jcs::to_vec(args)
561        .map_err(|e| FirewallError::Audit(vigil_audit::AuditError::Json(e)))?;
562    let mut h = Sha256::new();
563    h.update(&bytes);
564    Ok(hex::encode(h.finalize()))
565}