Skip to main content

agentshield/adapter/gpt_actions/
mod.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
7pub mod auth;
8pub mod endpoints;
9pub mod openapi;
10
11use std::path::Path;
12
13use crate::config::ScanPathFilter;
14use crate::error::Result;
15use crate::ir::execution_surface::ExecutionSurface;
16use crate::ir::taint_builder::build_data_surface;
17use crate::ir::tool_surface::ToolSurface;
18use crate::ir::*;
19
20pub(crate) use endpoints::{extract_openai_tools_json, extract_path_tools, extract_server_urls};
21pub(crate) use openapi::{
22    OPENAI_TOOL_FILENAMES, OPENAPI_FILENAMES, collect_spec_source_files, find_openapi_spec,
23    has_plugin_manifest, parse_openapi_spec,
24};
25
26/// GPT Actions and OpenAI Tools adapter.
27///
28/// Detects OpenAPI specs and OpenAI tool/function schemas by looking for:
29/// - `ai-plugin.json` (legacy ChatGPT plugin manifest)
30/// - `.well-known/ai-plugin.json`
31/// - `openapi.json` / `openapi.yaml` / `swagger.json` / `swagger.yaml`
32/// - `tools.json` / `functions.json` / `assistant.json`
33/// - `actions.json`
34pub struct GptActionsAdapter;
35
36impl super::Adapter for GptActionsAdapter {
37    fn framework(&self) -> Framework {
38        Framework::GptActions
39    }
40
41    fn detect(&self, root: &Path) -> bool {
42        // Legacy plugin manifest at root or .well-known/
43        if has_plugin_manifest(root) {
44            return true;
45        }
46
47        // OpenAPI / Swagger specs
48        for filename in OPENAPI_FILENAMES {
49            let path = root.join(filename);
50            if path.exists() {
51                if let Ok(content) = std::fs::read_to_string(&path) {
52                    if content.contains("x-openai-")
53                        || content.contains("x-openai")
54                        || content.contains("\"openapi\"")
55                        || content.contains("openapi:")
56                        || content.contains("\"swagger\"")
57                        || content.contains("swagger:")
58                    {
59                        return true;
60                    }
61                }
62            }
63        }
64
65        // OpenAI tools/functions definitions
66        for filename in OPENAI_TOOL_FILENAMES {
67            let path = root.join(filename);
68            if path.exists() {
69                if let Ok(content) = std::fs::read_to_string(&path) {
70                    if content.contains("\"function\"")
71                        || content.contains("\"parameters\"")
72                        || content.contains("parameters:")
73                    {
74                        return true;
75                    }
76                }
77            }
78        }
79
80        false
81    }
82
83    fn load(&self, root: &Path, ignore_tests: bool) -> Result<Vec<ScanTarget>> {
84        let filter = ScanPathFilter::for_ignore_tests(ignore_tests);
85        self.load_with_filter(root, &filter)
86    }
87
88    fn load_with_filter(&self, root: &Path, filter: &ScanPathFilter) -> Result<Vec<ScanTarget>> {
89        let name = root
90            .file_name()
91            .map(|n| n.to_string_lossy().to_string())
92            .unwrap_or_else(|| "gpt-actions".into());
93
94        let mut tools: Vec<ToolSurface> = Vec::new();
95        let mut execution = ExecutionSurface::default();
96
97        // Find the OpenAPI spec (prefer openapi.json, then others)
98        let spec_path = find_openapi_spec(root, filter);
99
100        if let Some(spec_path) = spec_path {
101            if let Ok(spec) = parse_openapi_spec(&spec_path) {
102                // Extract server URLs as network operations
103                extract_server_urls(&spec, &spec_path, &mut execution);
104
105                // Extract paths as tool surfaces
106                extract_path_tools(&spec, &spec_path, &mut tools);
107
108                // Extract security schemes
109                auth::extract_security_schemes(&spec, &spec_path, &mut execution);
110            }
111        }
112
113        // Extract OpenAI function/tool definitions
114        for filename in OPENAI_TOOL_FILENAMES {
115            let tool_path = root.join(filename);
116            if tool_path.exists() && filter.allows_path(root, &tool_path) {
117                if let Ok(content) = std::fs::read_to_string(&tool_path) {
118                    if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
119                        extract_openai_tools_json(&val, &tool_path, &mut tools);
120                    } else if let Ok(val) = serde_yaml::from_str::<serde_json::Value>(&content) {
121                        extract_openai_tools_json(&val, &tool_path, &mut tools);
122                    }
123                }
124            }
125        }
126
127        let source_files = collect_spec_source_files(root, filter);
128        let dependencies = super::mcp::parse_dependencies(root, filter);
129        let provenance = super::mcp::parse_provenance(root, filter);
130        let data = build_data_surface(&tools, &execution);
131
132        Ok(vec![ScanTarget {
133            name,
134            framework: Framework::GptActions,
135            root_path: root.to_path_buf(),
136            tools,
137            execution,
138            data,
139            dependencies,
140            provenance,
141            source_files,
142        }])
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use crate::adapter::Adapter;
150    use std::path::PathBuf;
151
152    #[test]
153    fn test_extract_openai_tools_json() {
154        let schema_json = serde_json::json!([
155            {
156                "type": "function",
157                "function": {
158                    "name": "search_database",
159                    "description": "Query the database for records",
160                    "parameters": {
161                        "type": "object",
162                        "properties": {
163                            "query": { "type": "string" }
164                        }
165                    }
166                }
167            }
168        ]);
169
170        let mut tools = Vec::new();
171        extract_openai_tools_json(&schema_json, Path::new("tools.json"), &mut tools);
172        assert_eq!(tools.len(), 1);
173        assert_eq!(tools[0].name, "search_database");
174        assert_eq!(
175            tools[0].description.as_deref(),
176            Some("Query the database for records")
177        );
178        assert!(tools[0].input_schema.is_some());
179    }
180
181    fn fixture_dir() -> PathBuf {
182        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/gpt_actions")
183    }
184
185    #[test]
186    fn test_detect_gpt_actions() {
187        let dir = fixture_dir();
188        let adapter = GptActionsAdapter;
189        assert!(
190            adapter.detect(&dir),
191            "should detect GPT Actions fixture with ai-plugin.json + openapi.json"
192        );
193    }
194
195    #[test]
196    fn test_detect_non_gpt_project() {
197        let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
198            .join("tests/fixtures/mcp_servers/safe_calculator");
199        let adapter = GptActionsAdapter;
200        assert!(
201            !adapter.detect(&dir),
202            "should not detect GPT Actions in an MCP calculator fixture"
203        );
204    }
205
206    #[test]
207    fn test_load_gpt_actions_tools() {
208        let dir = fixture_dir();
209        let adapter = GptActionsAdapter;
210        let targets = adapter.load(&dir, false).unwrap();
211        assert_eq!(targets.len(), 1);
212
213        let target = &targets[0];
214        assert_eq!(target.framework, Framework::GptActions);
215
216        // Fixture has /forecast (GET) and /alerts (GET) = 2 tools
217        assert!(
218            target.tools.len() >= 2,
219            "expected at least 2 tools from openapi.json paths, got {}",
220            target.tools.len()
221        );
222
223        // Tool names follow "{method}_{path}" format
224        let tool_names: Vec<&str> = target.tools.iter().map(|t| t.name.as_str()).collect();
225        assert!(
226            tool_names.contains(&"get_/forecast"),
227            "expected 'get_/forecast' tool"
228        );
229        assert!(
230            tool_names.contains(&"get_/alerts"),
231            "expected 'get_/alerts' tool"
232        );
233    }
234
235    #[test]
236    fn test_load_gpt_actions_input_schema() {
237        let dir = fixture_dir();
238        let adapter = GptActionsAdapter;
239        let targets = adapter.load(&dir, false).unwrap();
240        let target = &targets[0];
241
242        // /forecast has parameters: location (required), days (optional)
243        let forecast_tool = target
244            .tools
245            .iter()
246            .find(|t| t.name == "get_/forecast")
247            .expect("get_/forecast tool not found");
248
249        let schema = forecast_tool
250            .input_schema
251            .as_ref()
252            .expect("input_schema should be present");
253        let props = schema
254            .get("properties")
255            .and_then(|v| v.as_object())
256            .expect("properties should be an object");
257
258        assert!(
259            props.contains_key("location"),
260            "expected 'location' parameter"
261        );
262        assert!(props.contains_key("days"), "expected 'days' parameter");
263    }
264
265    #[test]
266    fn test_load_gpt_actions_network_operations() {
267        let dir = fixture_dir();
268        let adapter = GptActionsAdapter;
269        let targets = adapter.load(&dir, false).unwrap();
270        let target = &targets[0];
271
272        // openapi.json has servers: [{ url: "https://api.weather.example.com" }]
273        assert!(
274            !target.execution.network_operations.is_empty(),
275            "expected network operations from servers array"
276        );
277
278        let server_url = target
279            .execution
280            .network_operations
281            .iter()
282            .find(|op| matches!(&op.url_arg, ArgumentSource::Literal(u) if u.contains("weather.example.com")));
283        assert!(
284            server_url.is_some(),
285            "expected weather.example.com server URL"
286        );
287    }
288
289    #[test]
290    fn test_load_gpt_actions_source_files() {
291        let dir = fixture_dir();
292        let adapter = GptActionsAdapter;
293        let targets = adapter.load(&dir, false).unwrap();
294        let target = &targets[0];
295
296        // Should include openapi.json and ai-plugin.json
297        assert!(
298            !target.source_files.is_empty(),
299            "expected source files from fixture directory"
300        );
301
302        let file_names: Vec<String> = target
303            .source_files
304            .iter()
305            .map(|sf| {
306                sf.path
307                    .file_name()
308                    .unwrap_or_default()
309                    .to_string_lossy()
310                    .to_string()
311            })
312            .collect();
313
314        assert!(
315            file_names.contains(&"openapi.json".to_string()),
316            "expected openapi.json in source files"
317        );
318    }
319}