Skip to main content

vigil_sdk/
firewall_builder.rs

1//! 高层 firewall facade(SDK-owned)—— 让消费者**只用 `vigil-sdk`** 就能起一个可用 firewall
2//! 并跑一次决策,无需自行装配 `Ledger` / `PolicyEngine` / `DescriptorOracle`(均内化为实现细节,
3//! 不进 SDK public API,守住 SDK 边界)。
4//!
5//! 设计见 `docs/operations/sdk-firewall-builder/spike.md`(O-A:internalize,非 re-export internals)。
6//!
7//! ## 安全默认(fail-closed)
8//!
9//! builder 内部用 [`vigil_mcp::RegistryDescriptorOracle`] 作 descriptor oracle:全新(in-memory)
10//! ledger 上一切工具描述符是 `FirstSeen` → firewall risk scorer 视为**谨慎**(非 blanket-allow)。
11//! **绝不**用 `StaticDescriptorOracle(ApprovedStable)`(那会 defeat descriptor pinning)。消费者按需
12//! 注册并审批工具描述符后,匹配的 call 才走 `ApprovedStable` 快路径。
13//!
14//! ## 审计
15//!
16//! 默认 ledger 是 **in-memory**(零配置;进程内 `decide` 仍写 `DecisionRecord` 入账本,但**不跨进程
17//! 持久化**)。需要持久审计的消费者用 [`FirewallBuilder::ledger_path`] 指定文件路径。
18
19use std::path::PathBuf;
20use std::sync::Arc;
21
22use vigil_audit::Ledger;
23use vigil_firewall::scorer::DescriptorOracle;
24use vigil_mcp::RegistryDescriptorOracle;
25use vigil_policy::{defaults::default_ruleset, PolicyEngine};
26use vigil_types::ToolInvocation;
27
28use crate::{Firewall, FirewallConfig, FirewallError, FirewallOutcome, OAuthScopeContext};
29
30/// 装配一个可用 [`SdkFirewall`] 的 builder。
31///
32/// 默认:in-memory ledger + Vigil 默认策略规则集 + fail-closed [`RegistryDescriptorOracle`]。
33///
34/// ```
35/// use vigil_sdk::FirewallBuilder;
36/// let fw = FirewallBuilder::new().project_roots(["/proj"]).build().unwrap();
37/// ```
38#[derive(Debug, Clone, Default)]
39pub struct FirewallBuilder {
40    project_roots: Vec<String>,
41    allowed_hosts: Vec<String>,
42    ledger_path: Option<PathBuf>,
43}
44
45impl FirewallBuilder {
46    /// 新建 builder(默认 in-memory ledger,无 project root / allowed host)。
47    pub fn new() -> Self {
48        Self::default()
49    }
50
51    /// 设置 POSIX 规范化的项目根目录前缀(firewall 用于判定 file effect 是否在项目内)。
52    pub fn project_roots<I, S>(mut self, roots: I) -> Self
53    where
54        I: IntoIterator<Item = S>,
55        S: Into<String>,
56    {
57        self.project_roots = roots.into_iter().map(Into::into).collect();
58        self
59    }
60
61    /// 设置允许的出站主机列表(firewall 用于网络 effect 评估)。
62    pub fn allowed_hosts<I, S>(mut self, hosts: I) -> Self
63    where
64        I: IntoIterator<Item = S>,
65        S: Into<String>,
66    {
67        self.allowed_hosts = hosts.into_iter().map(Into::into).collect();
68        self
69    }
70
71    /// 用**文件持久化**的审计账本替代默认 in-memory(跨进程不丢审计)。
72    pub fn ledger_path(mut self, path: impl Into<PathBuf>) -> Self {
73        self.ledger_path = Some(path.into());
74        self
75    }
76
77    /// 装配 [`SdkFirewall`]:打开账本 → 默认策略引擎 → firewall + fail-closed oracle(共享同一账本)。
78    ///
79    /// 失败仅可能在账本打开阶段(in-memory 初始化 / 文件路径不可用),返 [`FirewallBuildError`]。
80    pub fn build(self) -> Result<SdkFirewall, FirewallBuildError> {
81        let ledger = match self.ledger_path {
82            Some(path) => Ledger::open(path),
83            None => Ledger::open_in_memory(),
84        }
85        .map_err(|e| FirewallBuildError::LedgerOpen {
86            reason: e.to_string(),
87        })?;
88        let ledger = Arc::new(ledger);
89
90        let policy = PolicyEngine::new(default_ruleset());
91        let config = FirewallConfig {
92            project_roots: self.project_roots,
93            allowed_hosts: self.allowed_hosts,
94            ..Default::default()
95        };
96        // firewall 与 oracle 共享**同一** Arc<Ledger>:oracle 查的 descriptor 审批状态
97        // 与 firewall 写的 DecisionRecord 在同一账本,语义一致。
98        let fw = Firewall::new(Arc::clone(&ledger), policy, config);
99        let oracle = RegistryDescriptorOracle::new(ledger);
100
101        Ok(SdkFirewall { fw, oracle })
102    }
103}
104
105/// 一个就绪的 firewall —— SDK-owned thin wrapper,**不**泄露底层 `Firewall` / `evaluate`,
106/// 消费者无法绕过内化的 fail-closed 默认 oracle。
107#[derive(Debug)]
108pub struct SdkFirewall {
109    fw: Firewall,
110    oracle: RegistryDescriptorOracle,
111}
112
113impl SdkFirewall {
114    /// 对一次工具调用跑 firewall 决策。
115    ///
116    /// 内部用 builder 装配的 fail-closed oracle + 非-OAuth scope。返回三态之一
117    /// ([`FirewallOutcome::Allowed`] / [`FirewallOutcome::Denied`] / [`FirewallOutcome::Approve`]),
118    /// **均已把 `DecisionRecord` 写入账本**(SDK 不变量:effect 触发前必产决策记录)。
119    ///
120    /// **fail-closed**:返 `Err(FirewallError)` 时,消费者**必须**当作 deny(SDK 不变量 #1),
121    /// 不可降级为放行。
122    pub fn decide(&self, call: &ToolInvocation) -> Result<FirewallOutcome, FirewallError> {
123        self.fw.evaluate(
124            call,
125            &self.oracle as &dyn DescriptorOracle,
126            OAuthScopeContext::NonOauth,
127        )
128    }
129
130    /// 便捷决策(ergonomic)—— 从 `(server_id, tool_name, args)` 构造一次性 [`ToolInvocation`]
131    /// 并 [`decide`](Self::decide)。免去消费者手填 Vigil 内部样板字段。
132    ///
133    /// 自动填充:`invocation_id`(UUIDv4)、`requested_at`(当前 Unix epoch 秒)、`session_id`
134    /// (固定 `"sdk"`)、`descriptor_hash`(每次唯一的非-pinnable 占位,见下)。
135    ///
136    /// **不做 descriptor pinning,且 fail-closed by construction**:`descriptor_hash` 填一个**每次
137    /// 调用唯一**的占位(`sdk-decide-call:<uuid>`)——它**永不可能等于**任何已 pin 的描述符 hash
138    /// (后者是 sha256 hex),故 oracle 的 `pinned == call` 相等分支(→ `ApprovedStable`)**构造上不可达**,
139    /// 结果恒为 `FirstSeen`(谨慎)。**不依赖**"空 hash 不会被 pin"这种约定(Codex R1:空占位非
140    /// fail-closed by construction)。需要让**已审批**工具走 `ApprovedStable` 快路径(descriptor
141    /// pinning)的消费者,改用 [`decide`](Self::decide) 传入带真实 `descriptor_hash` 的完整 [`ToolInvocation`]。
142    ///
143    /// ```
144    /// use vigil_sdk::prelude::*;
145    /// let fw = FirewallBuilder::new().project_roots(["/proj"]).build().unwrap();
146    /// // 一行跑一次决策:项目外写 → Deny
147    /// let outcome = fw
148    ///     .decide_call("fs", "fs_write_file", serde_json::json!({"path": "/etc/hosts"}))
149    ///     .unwrap();
150    /// assert_eq!(outcome.decision_kind(), DecisionKind::Deny);
151    /// ```
152    pub fn decide_call(
153        &self,
154        server_id: &str,
155        tool_name: &str,
156        args: serde_json::Value,
157    ) -> Result<FirewallOutcome, FirewallError> {
158        let requested_at = std::time::SystemTime::now()
159            .duration_since(std::time::UNIX_EPOCH)
160            .map(|d| d.as_secs() as i64)
161            .unwrap_or(0);
162        let call = ToolInvocation {
163            invocation_id: uuid::Uuid::new_v4().to_string(),
164            session_id: "sdk".into(),
165            server_id: server_id.into(),
166            tool_name: tool_name.into(),
167            args,
168            // fail-closed by construction:每次唯一、非 sha256 形态 → 永不匹配已 pin 描述符 hash
169            // → oracle 恒 FirstSeen(不依赖"空 hash 不会被 pin"的约定;Codex R1 BLOCKER)。
170            descriptor_hash: format!("sdk-decide-call:{}", uuid::Uuid::new_v4()),
171            requested_at,
172        };
173        self.decide(&call)
174    }
175}
176
177/// [`FirewallBuilder::build`] 的错误(SDK-owned,`#[non_exhaustive]` 允许将来加 variant 不破 SemVer)。
178#[derive(Debug)]
179#[non_exhaustive]
180pub enum FirewallBuildError {
181    /// 审计账本打开失败(in-memory 初始化或文件路径不可用)。`reason` 为脱敏文本(无原文 / 无 PII)。
182    LedgerOpen {
183        /// 失败原因(stable 文本)。
184        reason: String,
185    },
186}
187
188impl std::fmt::Display for FirewallBuildError {
189    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
190        match self {
191            Self::LedgerOpen { reason } => {
192                write!(f, "firewall build: ledger open failed: {reason}")
193            }
194        }
195    }
196}
197
198impl std::error::Error for FirewallBuildError {}
199
200#[cfg(test)]
201#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
202mod tests {
203    use super::*;
204    use vigil_types::DecisionKind;
205
206    fn mk_call(tool: &str, args: serde_json::Value) -> ToolInvocation {
207        ToolInvocation {
208            invocation_id: "test-invocation-id".into(),
209            session_id: "test-session".into(),
210            server_id: "test-srv".into(),
211            tool_name: tool.into(),
212            args,
213            descriptor_hash: "test-hash".into(),
214            requested_at: 0,
215        }
216    }
217
218    /// criterion #1:builder 装配成功(in-memory ledger + default ruleset + oracle)。
219    #[test]
220    fn build_assembles_usable_firewall() {
221        let fw = FirewallBuilder::new().project_roots(["/proj"]).build();
222        assert!(
223            fw.is_ok(),
224            "default build should succeed, got {:?}",
225            fw.err()
226        );
227    }
228
229    /// ★ Codex code review BLOCKER 修复 —— **辨别性** fail-closed 证明:
230    /// 同一 in-repo 读在 `StaticDescriptorOracle(ApprovedStable)` 下是 **Allow**(vigil-firewall
231    /// §3.5-1);但 builder 默认用 fail-closed `RegistryDescriptorOracle`(fresh ledger 上工具描述符
232    /// = `FirstSeen`)→ 触发 "首见描述符需审批" → **Approve**(非 Allow)。断言 Approve 即**区分**出
233    /// 默认 oracle 确是 fail-closed,而非 blanket-allow(deny-stays-deny 测试两种 oracle 都过,不能区分)。
234    #[test]
235    fn decide_fresh_repo_read_is_approve_not_blanket_allow() {
236        let fw = FirewallBuilder::new()
237            .project_roots(["/proj"])
238            .build()
239            .unwrap();
240        let call = mk_call(
241            "fs_read_file",
242            serde_json::json!({"path": "/proj/src/main.rs"}),
243        );
244        let outcome = fw.decide(&call).expect("decide should succeed");
245        assert_eq!(
246            outcome.decision_kind(),
247            DecisionKind::Approve,
248            "fresh ledger 上 in-repo 读应因 FirstSeen 走 Approve(证明默认 oracle = RegistryDescriptorOracle,非 ApprovedStable blanket-allow)"
249        );
250    }
251
252    /// criterion #2(fail-closed 实证,robust):危险调用必 Deny —— FirstSeen 只增不减 risk,
253    /// 故 ApprovedStable 下就 Deny 的调用在 builder 默认(RegistryDescriptorOracle,fresh=FirstSeen)
254    /// 下仍 Deny。证明 facade 真做风险评估而非 rubber-stamp。
255    #[test]
256    fn decide_denies_write_outside_project() {
257        let fw = FirewallBuilder::new()
258            .project_roots(["/proj"])
259            .build()
260            .unwrap();
261        let call = mk_call("fs_write_file", serde_json::json!({"path": "/etc/hosts"}));
262        let outcome = fw.decide(&call).expect("decide should succeed");
263        assert_eq!(
264            outcome.decision_kind(),
265            DecisionKind::Deny,
266            "项目外写必须 Deny(fail-closed)"
267        );
268    }
269
270    /// criterion #2(续):破坏性 shell 必 Deny。
271    #[test]
272    fn decide_denies_destructive_shell() {
273        let fw = FirewallBuilder::new()
274            .project_roots(["/proj"])
275            .build()
276            .unwrap();
277        let call = mk_call(
278            "shell_run",
279            serde_json::json!({"argv": ["rm", "-rf", "/home/user/Downloads"]}),
280        );
281        let outcome = fw.decide(&call).expect("decide should succeed");
282        assert_eq!(
283            outcome.decision_kind(),
284            DecisionKind::Deny,
285            "rm -rf 必须 Deny"
286        );
287    }
288
289    /// 默认 in-memory 不依赖文件;ledger_path 可选(文件持久化)路径也能 build。
290    #[test]
291    fn build_with_ledger_path() {
292        let tmp = std::env::temp_dir().join("vigil_sdk_fwbuilder_test.sqlite3");
293        let _ = std::fs::remove_file(&tmp);
294        let fw = FirewallBuilder::new().ledger_path(&tmp).build();
295        assert!(
296            fw.is_ok(),
297            "file-backed ledger build should succeed, got {:?}",
298            fw.err()
299        );
300        let _ = std::fs::remove_file(&tmp);
301    }
302
303    /// decide_call 便捷路径与 decide 语义一致:危险调用同样 Deny。
304    #[test]
305    fn decide_call_denies_dangerous_call() {
306        let fw = FirewallBuilder::new()
307            .project_roots(["/proj"])
308            .build()
309            .unwrap();
310        let outcome = fw
311            .decide_call(
312                "fs",
313                "fs_write_file",
314                serde_json::json!({"path": "/etc/hosts"}),
315            )
316            .expect("decide_call should succeed");
317        assert_eq!(outcome.decision_kind(), DecisionKind::Deny);
318    }
319
320    /// decide_call 同样走 fail-closed 默认 oracle:fresh ledger 上 in-repo 读 → Approve(FirstSeen),
321    /// 与 decide() 一致(空 descriptor_hash 不破 fail-closed,因 fresh ledger 本就 FirstSeen)。
322    #[test]
323    fn decide_call_fresh_repo_read_is_approve() {
324        let fw = FirewallBuilder::new()
325            .project_roots(["/proj"])
326            .build()
327            .unwrap();
328        let outcome = fw
329            .decide_call(
330                "fs",
331                "fs_read_file",
332                serde_json::json!({"path": "/proj/src/main.rs"}),
333            )
334            .expect("decide_call should succeed");
335        assert_eq!(outcome.decision_kind(), DecisionKind::Approve);
336    }
337}