Skip to main content

agentshield/adapter/
gpt_actions.rs

1//! GPT Actions adapter.
2//!
3//! Detects OpenAPI specs used by ChatGPT custom actions (GPTs / plugin manifests)
4//! and loads each path+method combination as a `ToolSurface`. Server URLs are
5//! emitted as `NetworkOperation` entries so SSRF detectors can evaluate them.
6
7use 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
18/// OpenAPI spec filenames that GPT Actions typically use.
19const OPENAPI_FILENAMES: &[&str] = &[
20    "openapi.json",
21    "openapi.yaml",
22    "openapi.yml",
23    "swagger.json",
24    "swagger.yaml",
25    "swagger.yml",
26];
27
28/// Legacy ChatGPT plugin manifest filenames.
29const PLUGIN_MANIFEST_FILENAMES: &[&str] = &["ai-plugin.json", "actions.json"];
30
31/// OpenAI function and tool definition filenames.
32const OPENAI_TOOL_FILENAMES: &[&str] = &[
33    "tools.json",
34    "functions.json",
35    "assistant.json",
36    "tools.yaml",
37    "tools.yml",
38];
39
40/// GPT Actions and OpenAI Tools adapter.
41///
42/// Detects OpenAPI specs and OpenAI tool/function schemas by looking for:
43/// - `ai-plugin.json` (legacy ChatGPT plugin manifest)
44/// - `.well-known/ai-plugin.json`
45/// - `openapi.json` / `openapi.yaml` / `swagger.json` / `swagger.yaml`
46/// - `tools.json` / `functions.json` / `assistant.json`
47/// - `actions.json`
48pub 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        // Legacy plugin manifest at root or .well-known/
57        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        // OpenAPI spec with x-openai-* extensions
67        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                    // JSON spec: check for openapi version field alongside plugin manifest check
75                    if content.contains("\"openapi\"") || content.contains("openapi:") {
76                        // Also accept if ai-plugin.json exists anywhere nearby
77                        if has_plugin_manifest(root) {
78                            return true;
79                        }
80                    }
81                }
82            }
83        }
84
85        // OpenAI tools/functions definitions
86        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        // Find the OpenAPI spec (prefer openapi.json, then others)
118        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 as network operations
123                extract_server_urls(&spec, &spec_path, &mut execution);
124
125                // Extract paths as tool surfaces
126                extract_path_tools(&spec, &spec_path, &mut tools);
127            }
128        }
129
130        // Extract OpenAI function/tool definitions
131        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
163/// Check whether any plugin manifest file exists under root.
164fn 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
173/// Find the first OpenAPI spec file present under root, in preference order.
174fn 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
184/// Extract server URLs from the OpenAPI `servers` array and emit them as
185/// `NetworkOperation` entries. This lets SSRF and data-exfiltration detectors
186/// inspect the domains the action contacts.
187fn 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 numbers are not easily derivable from parsed JSON; use index as proxy
216                line: idx + 1,
217                column: 0,
218                end_line: None,
219                end_column: None,
220            },
221        });
222    }
223}
224
225/// Extract each OpenAPI path+method as a `ToolSurface`.
226///
227/// Name format: `{method}_{path}` (e.g. `get_/forecast`).
228/// Operation parameters are mapped to the input schema `properties`.
229fn 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
281/// Build a JSON Schema `properties` object from the operation's `parameters`
282/// and `requestBody`, mirroring the shape expected by downstream detectors.
283fn 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    // Path / query / header parameters
288    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    // requestBody (JSON only)
311    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
337/// Collect OpenAPI spec files and plugin manifests as `SourceFile` entries.
338///
339/// We do not parse them with language parsers (there is no Rust/Python source),
340/// but including them lets detectors and output formatters reference them.
341fn 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        // Fixture has /forecast (GET) and /alerts (GET) = 2 tools
542        assert!(
543            target.tools.len() >= 2,
544            "expected at least 2 tools from openapi.json paths, got {}",
545            target.tools.len()
546        );
547
548        // Tool names follow "{method}_{path}" format
549        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        // /forecast has parameters: location (required), days (optional)
568        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        // openapi.json has servers: [{ url: "https://api.weather.example.com" }]
598        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        // Should include openapi.json and ai-plugin.json
622        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}