1use 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#[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 pub fn new() -> Self {
48 Self::default()
49 }
50
51 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 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 pub fn ledger_path(mut self, path: impl Into<PathBuf>) -> Self {
73 self.ledger_path = Some(path.into());
74 self
75 }
76
77 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 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#[derive(Debug)]
108pub struct SdkFirewall {
109 fw: Firewall,
110 oracle: RegistryDescriptorOracle,
111}
112
113impl SdkFirewall {
114 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 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 descriptor_hash: format!("sdk-decide-call:{}", uuid::Uuid::new_v4()),
171 requested_at,
172 };
173 self.decide(&call)
174 }
175}
176
177#[derive(Debug)]
179#[non_exhaustive]
180pub enum FirewallBuildError {
181 LedgerOpen {
183 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}