1use std::path::{Path, PathBuf};
8
9use crate::config::ScanPathFilter;
10use crate::error::Result;
11use crate::ir::execution_surface::{ExecutionSurface, NetworkOperation};
12use crate::ir::taint_builder::build_data_surface;
13use crate::ir::tool_surface::ToolSurface;
14use crate::ir::*;
15
16const OPENAPI_EXTENSIONS: &[&str] = &["json", "yaml", "yml"];
17
18const OPENAPI_FILENAMES: &[&str] = &[
20 "openapi.json",
21 "openapi.yaml",
22 "openapi.yml",
23 "swagger.json",
24 "swagger.yaml",
25 "swagger.yml",
26];
27
28const PLUGIN_MANIFEST_FILENAMES: &[&str] = &["ai-plugin.json", "actions.json"];
30
31const OPENAI_TOOL_FILENAMES: &[&str] = &[
33 "tools.json",
34 "functions.json",
35 "assistant.json",
36 "tools.yaml",
37 "tools.yml",
38];
39
40pub struct GptActionsAdapter;
49
50impl super::Adapter for GptActionsAdapter {
51 fn framework(&self) -> Framework {
52 Framework::GptActions
53 }
54
55 fn detect(&self, root: &Path) -> bool {
56 for filename in PLUGIN_MANIFEST_FILENAMES {
58 if root.join(filename).exists() {
59 return true;
60 }
61 }
62 if root.join(".well-known").join("ai-plugin.json").exists() {
63 return true;
64 }
65
66 for filename in OPENAPI_FILENAMES {
68 let path = root.join(filename);
69 if path.exists() {
70 if let Ok(content) = std::fs::read_to_string(&path) {
71 if content.contains("x-openai-") || content.contains("x-openai") {
72 return true;
73 }
74 if content.contains("\"openapi\"") || content.contains("openapi:") {
76 if has_plugin_manifest(root) {
78 return true;
79 }
80 }
81 }
82 }
83 }
84
85 for filename in OPENAI_TOOL_FILENAMES {
87 let path = root.join(filename);
88 if path.exists() {
89 if let Ok(content) = std::fs::read_to_string(&path) {
90 if content.contains("\"function\"")
91 || content.contains("\"parameters\"")
92 || content.contains("parameters:")
93 {
94 return true;
95 }
96 }
97 }
98 }
99
100 false
101 }
102
103 fn load(&self, root: &Path, ignore_tests: bool) -> Result<Vec<ScanTarget>> {
104 let filter = ScanPathFilter::for_ignore_tests(ignore_tests);
105 self.load_with_filter(root, &filter)
106 }
107
108 fn load_with_filter(&self, root: &Path, filter: &ScanPathFilter) -> Result<Vec<ScanTarget>> {
109 let name = root
110 .file_name()
111 .map(|n| n.to_string_lossy().to_string())
112 .unwrap_or_else(|| "gpt-actions".into());
113
114 let mut tools: Vec<ToolSurface> = Vec::new();
115 let mut execution = ExecutionSurface::default();
116
117 let spec_path = find_openapi_spec(root, filter);
119
120 if let Some(spec_path) = spec_path {
121 if let Ok(spec) = parse_openapi_spec(&spec_path) {
122 extract_server_urls(&spec, &spec_path, &mut execution);
124
125 extract_path_tools(&spec, &spec_path, &mut tools);
127 }
128 }
129
130 for filename in OPENAI_TOOL_FILENAMES {
132 let tool_path = root.join(filename);
133 if tool_path.exists() && filter.allows_path(root, &tool_path) {
134 if let Ok(content) = std::fs::read_to_string(&tool_path) {
135 if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
136 extract_openai_tools_json(&val, &tool_path, &mut tools);
137 } else if let Ok(val) = serde_yaml::from_str::<serde_json::Value>(&content) {
138 extract_openai_tools_json(&val, &tool_path, &mut tools);
139 }
140 }
141 }
142 }
143
144 let source_files = collect_spec_source_files(root, filter);
145 let dependencies = super::mcp::parse_dependencies(root, filter);
146 let provenance = super::mcp::parse_provenance(root, filter);
147 let data = build_data_surface(&tools, &execution);
148
149 Ok(vec![ScanTarget {
150 name,
151 framework: Framework::GptActions,
152 root_path: root.to_path_buf(),
153 tools,
154 execution,
155 data,
156 dependencies,
157 provenance,
158 source_files,
159 }])
160 }
161}
162
163fn has_plugin_manifest(root: &Path) -> bool {
165 for filename in PLUGIN_MANIFEST_FILENAMES {
166 if root.join(filename).exists() {
167 return true;
168 }
169 }
170 root.join(".well-known").join("ai-plugin.json").exists()
171}
172
173fn find_openapi_spec(root: &Path, filter: &ScanPathFilter) -> Option<PathBuf> {
175 for filename in OPENAPI_FILENAMES {
176 let path = root.join(filename);
177 if path.exists() && filter.allows_path(root, &path) {
178 return Some(path);
179 }
180 }
181 None
182}
183
184fn extract_server_urls(
188 spec: &serde_json::Value,
189 spec_path: &Path,
190 execution: &mut ExecutionSurface,
191) {
192 let servers = match spec.get("servers").and_then(|v| v.as_array()) {
193 Some(s) => s,
194 None => return,
195 };
196
197 for (idx, server) in servers.iter().enumerate() {
198 let url = server
199 .get("url")
200 .and_then(|v| v.as_str())
201 .unwrap_or("")
202 .to_string();
203
204 if url.is_empty() {
205 continue;
206 }
207
208 execution.network_operations.push(NetworkOperation {
209 function: "openapi_server".to_string(),
210 url_arg: ArgumentSource::Literal(url),
211 method: None,
212 sends_data: false,
213 location: SourceLocation {
214 file: spec_path.to_path_buf(),
215 line: idx + 1,
217 column: 0,
218 end_line: None,
219 end_column: None,
220 },
221 });
222 }
223}
224
225fn extract_path_tools(spec: &serde_json::Value, spec_path: &Path, tools: &mut Vec<ToolSurface>) {
230 let paths = match spec.get("paths").and_then(|v| v.as_object()) {
231 Some(p) => p,
232 None => return,
233 };
234
235 const HTTP_METHODS: &[&str] = &["get", "post", "put", "patch", "delete", "head", "options"];
236
237 for (path_str, path_item) in paths {
238 let path_obj = match path_item.as_object() {
239 Some(o) => o,
240 None => continue,
241 };
242
243 for method in HTTP_METHODS {
244 let operation = match path_obj.get(*method) {
245 Some(op) => op,
246 None => continue,
247 };
248
249 let tool_name = format!("{}_{}", method, path_str);
250 let description = operation
251 .get("summary")
252 .or_else(|| operation.get("description"))
253 .and_then(|v| v.as_str())
254 .map(|s| s.to_string());
255
256 let input_schema = build_input_schema_from_operation(operation);
257
258 tools.push(ToolSurface {
259 name: tool_name,
260 description,
261 input_schema: Some(input_schema),
262 output_schema: None,
263 declared_permissions: vec![],
264 defined_at: Some(SourceLocation {
265 file: spec_path.to_path_buf(),
266 line: 1,
267 column: 0,
268 end_line: None,
269 end_column: None,
270 }),
271 declared_capabilities: Default::default(),
272 capability_declarations: Vec::new(),
273 observed_capabilities: Default::default(),
274 capability_observation_complete: false,
275 capability_evidence: Vec::new(),
276 });
277 }
278 }
279}
280
281fn build_input_schema_from_operation(operation: &serde_json::Value) -> serde_json::Value {
284 let mut properties = serde_json::Map::new();
285 let mut required: Vec<serde_json::Value> = Vec::new();
286
287 if let Some(params) = operation.get("parameters").and_then(|v| v.as_array()) {
289 for param in params {
290 let name = match param.get("name").and_then(|v| v.as_str()) {
291 Some(n) => n,
292 None => continue,
293 };
294 let schema = param
295 .get("schema")
296 .cloned()
297 .unwrap_or_else(|| serde_json::json!({"type": "string"}));
298 properties.insert(name.to_string(), schema);
299
300 if param
301 .get("required")
302 .and_then(|v| v.as_bool())
303 .unwrap_or(false)
304 {
305 required.push(serde_json::Value::String(name.to_string()));
306 }
307 }
308 }
309
310 if let Some(rb_schema) = operation
312 .get("requestBody")
313 .and_then(|rb| rb.get("content"))
314 .and_then(|c| c.get("application/json"))
315 .and_then(|m| m.get("schema"))
316 {
317 if let Some(props) = rb_schema.get("properties").and_then(|v| v.as_object()) {
318 for (k, v) in props {
319 properties.insert(k.clone(), v.clone());
320 }
321 }
322 if let Some(req_arr) = rb_schema.get("required").and_then(|v| v.as_array()) {
323 required.extend(req_arr.iter().cloned());
324 }
325 }
326
327 let mut schema = serde_json::json!({
328 "type": "object",
329 "properties": serde_json::Value::Object(properties)
330 });
331 if !required.is_empty() {
332 schema["required"] = serde_json::Value::Array(required);
333 }
334 schema
335}
336
337fn collect_spec_source_files(root: &Path, filter: &ScanPathFilter) -> Vec<SourceFile> {
342 let mut files = Vec::new();
343
344 let candidates: Vec<PathBuf> = OPENAPI_FILENAMES
345 .iter()
346 .chain(PLUGIN_MANIFEST_FILENAMES.iter())
347 .map(|f| root.join(f))
348 .chain(std::iter::once(
349 root.join(".well-known").join("ai-plugin.json"),
350 ))
351 .collect();
352
353 for path in candidates {
354 if !path.exists() {
355 continue;
356 }
357 if !filter.allows_path(root, &path) {
358 continue;
359 }
360 let Ok(metadata) = std::fs::metadata(&path) else {
361 continue;
362 };
363 let Ok(content) = std::fs::read_to_string(&path) else {
364 continue;
365 };
366
367 let ext = path
368 .extension()
369 .map(|e| e.to_string_lossy().to_string())
370 .unwrap_or_default();
371 let lang = Language::from_extension(&ext);
372
373 let hash = format!(
374 "{:x}",
375 sha2::Digest::finalize(sha2::Sha256::new().chain_update(content.as_bytes()))
376 );
377
378 files.push(SourceFile {
379 path,
380 language: lang,
381 size_bytes: metadata.len(),
382 content_hash: hash,
383 content,
384 });
385 }
386
387 files
388}
389
390fn parse_openapi_spec(spec_path: &Path) -> Result<serde_json::Value> {
391 let content = std::fs::read_to_string(spec_path)?;
392 let extension = spec_path
393 .extension()
394 .and_then(|ext| ext.to_str())
395 .unwrap_or_default()
396 .to_ascii_lowercase();
397
398 if OPENAPI_EXTENSIONS.contains(&extension.as_str()) && extension != "json" {
399 let yaml: serde_yaml::Value =
400 serde_yaml::from_str(&content).map_err(|err| crate::error::ShieldError::Parse {
401 file: spec_path.display().to_string(),
402 message: format!("Failed to parse OpenAPI YAML: {err}"),
403 })?;
404 return serde_json::to_value(yaml).map_err(|err| crate::error::ShieldError::Parse {
405 file: spec_path.display().to_string(),
406 message: format!("Failed to convert OpenAPI YAML AST: {err}"),
407 });
408 }
409
410 serde_json::from_str(&content).map_err(|err| crate::error::ShieldError::Parse {
411 file: spec_path.display().to_string(),
412 message: format!("Failed to parse OpenAPI JSON: {err}"),
413 })
414}
415
416fn extract_openai_tools_json(
417 value: &serde_json::Value,
418 file_path: &Path,
419 tools: &mut Vec<ToolSurface>,
420) {
421 let items = if let Some(arr) = value.as_array() {
422 arr.as_slice()
423 } else if let Some(arr) = value.get("tools").and_then(|t| t.as_array()) {
424 arr.as_slice()
425 } else if let Some(arr) = value.get("functions").and_then(|f| f.as_array()) {
426 arr.as_slice()
427 } else {
428 return;
429 };
430
431 for item in items {
432 let func = if let Some(f) = item.get("function") {
433 f
434 } else {
435 item
436 };
437
438 let Some(name) = func.get("name").and_then(|n| n.as_str()) else {
439 continue;
440 };
441
442 let description = func
443 .get("description")
444 .and_then(|d| d.as_str())
445 .map(str::to_string);
446 let input_schema = func.get("parameters").cloned();
447
448 tools.push(ToolSurface {
449 name: name.to_string(),
450 description,
451 input_schema,
452 output_schema: None,
453 declared_permissions: Vec::new(),
454 defined_at: Some(SourceLocation {
455 file: file_path.to_path_buf(),
456 line: 1,
457 column: 0,
458 end_line: None,
459 end_column: None,
460 }),
461 declared_capabilities: Default::default(),
462 capability_declarations: Vec::new(),
463 observed_capabilities: Default::default(),
464 capability_observation_complete: false,
465 capability_evidence: Vec::new(),
466 });
467 }
468}
469
470use sha2::Digest;
471
472#[cfg(test)]
473mod tests {
474 use super::*;
475 use crate::adapter::Adapter;
476
477 #[test]
478 fn test_extract_openai_tools_json() {
479 let schema_json = serde_json::json!([
480 {
481 "type": "function",
482 "function": {
483 "name": "search_database",
484 "description": "Query the database for records",
485 "parameters": {
486 "type": "object",
487 "properties": {
488 "query": { "type": "string" }
489 }
490 }
491 }
492 }
493 ]);
494
495 let mut tools = Vec::new();
496 extract_openai_tools_json(&schema_json, Path::new("tools.json"), &mut tools);
497 assert_eq!(tools.len(), 1);
498 assert_eq!(tools[0].name, "search_database");
499 assert_eq!(
500 tools[0].description.as_deref(),
501 Some("Query the database for records")
502 );
503 assert!(tools[0].input_schema.is_some());
504 }
505
506 fn fixture_dir() -> PathBuf {
507 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/gpt_actions")
508 }
509
510 #[test]
511 fn test_detect_gpt_actions() {
512 let dir = fixture_dir();
513 let adapter = GptActionsAdapter;
514 assert!(
515 adapter.detect(&dir),
516 "should detect GPT Actions fixture with ai-plugin.json + openapi.json"
517 );
518 }
519
520 #[test]
521 fn test_detect_non_gpt_project() {
522 let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
523 .join("tests/fixtures/mcp_servers/safe_calculator");
524 let adapter = GptActionsAdapter;
525 assert!(
526 !adapter.detect(&dir),
527 "should not detect GPT Actions in an MCP calculator fixture"
528 );
529 }
530
531 #[test]
532 fn test_load_gpt_actions_tools() {
533 let dir = fixture_dir();
534 let adapter = GptActionsAdapter;
535 let targets = adapter.load(&dir, false).unwrap();
536 assert_eq!(targets.len(), 1);
537
538 let target = &targets[0];
539 assert_eq!(target.framework, Framework::GptActions);
540
541 assert!(
543 target.tools.len() >= 2,
544 "expected at least 2 tools from openapi.json paths, got {}",
545 target.tools.len()
546 );
547
548 let tool_names: Vec<&str> = target.tools.iter().map(|t| t.name.as_str()).collect();
550 assert!(
551 tool_names.contains(&"get_/forecast"),
552 "expected 'get_/forecast' tool"
553 );
554 assert!(
555 tool_names.contains(&"get_/alerts"),
556 "expected 'get_/alerts' tool"
557 );
558 }
559
560 #[test]
561 fn test_load_gpt_actions_input_schema() {
562 let dir = fixture_dir();
563 let adapter = GptActionsAdapter;
564 let targets = adapter.load(&dir, false).unwrap();
565 let target = &targets[0];
566
567 let forecast_tool = target
569 .tools
570 .iter()
571 .find(|t| t.name == "get_/forecast")
572 .expect("get_/forecast tool not found");
573
574 let schema = forecast_tool
575 .input_schema
576 .as_ref()
577 .expect("input_schema should be present");
578 let props = schema
579 .get("properties")
580 .and_then(|v| v.as_object())
581 .expect("properties should be an object");
582
583 assert!(
584 props.contains_key("location"),
585 "expected 'location' parameter"
586 );
587 assert!(props.contains_key("days"), "expected 'days' parameter");
588 }
589
590 #[test]
591 fn test_load_gpt_actions_network_operations() {
592 let dir = fixture_dir();
593 let adapter = GptActionsAdapter;
594 let targets = adapter.load(&dir, false).unwrap();
595 let target = &targets[0];
596
597 assert!(
599 !target.execution.network_operations.is_empty(),
600 "expected network operations from servers array"
601 );
602
603 let server_url = target
604 .execution
605 .network_operations
606 .iter()
607 .find(|op| matches!(&op.url_arg, ArgumentSource::Literal(u) if u.contains("weather.example.com")));
608 assert!(
609 server_url.is_some(),
610 "expected weather.example.com server URL"
611 );
612 }
613
614 #[test]
615 fn test_load_gpt_actions_source_files() {
616 let dir = fixture_dir();
617 let adapter = GptActionsAdapter;
618 let targets = adapter.load(&dir, false).unwrap();
619 let target = &targets[0];
620
621 assert!(
623 !target.source_files.is_empty(),
624 "expected source files from fixture directory"
625 );
626
627 let file_names: Vec<String> = target
628 .source_files
629 .iter()
630 .map(|sf| {
631 sf.path
632 .file_name()
633 .unwrap_or_default()
634 .to_string_lossy()
635 .to_string()
636 })
637 .collect();
638
639 assert!(
640 file_names.contains(&"openapi.json".to_string()),
641 "expected openapi.json in source files"
642 );
643 }
644}