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
16/// OpenAPI spec filenames that GPT Actions typically use.
17const OPENAPI_FILENAMES: &[&str] = &[
18    "openapi.json",
19    "openapi.yaml",
20    "openapi.yml",
21    "swagger.json",
22    "swagger.yaml",
23    "swagger.yml",
24];
25
26/// Legacy ChatGPT plugin manifest filenames.
27const PLUGIN_MANIFEST_FILENAMES: &[&str] = &["ai-plugin.json", "actions.json"];
28
29/// GPT Actions adapter.
30///
31/// Detects OpenAPI specs for ChatGPT custom actions by looking for:
32/// - `ai-plugin.json` (legacy ChatGPT plugin manifest)
33/// - `.well-known/ai-plugin.json`
34/// - `openapi.json` / `openapi.yaml` / `swagger.json` / `swagger.yaml`
35///   with `x-openai-*` extensions or alongside an `ai-plugin.json`
36/// - `actions.json`
37pub struct GptActionsAdapter;
38
39impl super::Adapter for GptActionsAdapter {
40    fn framework(&self) -> Framework {
41        Framework::GptActions
42    }
43
44    fn detect(&self, root: &Path) -> bool {
45        // Legacy plugin manifest at root or .well-known/
46        for filename in PLUGIN_MANIFEST_FILENAMES {
47            if root.join(filename).exists() {
48                return true;
49            }
50        }
51        if root.join(".well-known").join("ai-plugin.json").exists() {
52            return true;
53        }
54
55        // OpenAPI spec with x-openai-* extensions
56        for filename in OPENAPI_FILENAMES {
57            let path = root.join(filename);
58            if path.exists() {
59                if let Ok(content) = std::fs::read_to_string(&path) {
60                    if content.contains("x-openai-") || content.contains("x-openai") {
61                        return true;
62                    }
63                    // JSON spec: check for openapi version field alongside plugin manifest check
64                    if content.contains("\"openapi\"") || content.contains("openapi:") {
65                        // Also accept if ai-plugin.json exists anywhere nearby
66                        if has_plugin_manifest(root) {
67                            return true;
68                        }
69                    }
70                }
71            }
72        }
73
74        false
75    }
76
77    fn load(&self, root: &Path, ignore_tests: bool) -> Result<Vec<ScanTarget>> {
78        let filter = ScanPathFilter::for_ignore_tests(ignore_tests);
79        self.load_with_filter(root, &filter)
80    }
81
82    fn load_with_filter(&self, root: &Path, filter: &ScanPathFilter) -> Result<Vec<ScanTarget>> {
83        let name = root
84            .file_name()
85            .map(|n| n.to_string_lossy().to_string())
86            .unwrap_or_else(|| "gpt-actions".into());
87
88        let mut tools: Vec<ToolSurface> = Vec::new();
89        let mut execution = ExecutionSurface::default();
90
91        // Find the OpenAPI spec (prefer openapi.json, then others)
92        let spec_path = find_openapi_spec(root, filter);
93
94        if let Some(spec_path) = spec_path {
95            if let Ok(content) = std::fs::read_to_string(&spec_path) {
96                if let Ok(spec) = serde_json::from_str::<serde_json::Value>(&content) {
97                    // Extract server URLs as network operations
98                    extract_server_urls(&spec, &spec_path, &mut execution);
99
100                    // Extract paths as tool surfaces
101                    extract_path_tools(&spec, &spec_path, &mut tools);
102                }
103            }
104        }
105
106        let source_files = collect_spec_source_files(root, filter);
107        let dependencies = super::mcp::parse_dependencies(root, filter);
108        let provenance = super::mcp::parse_provenance(root, filter);
109        let data = build_data_surface(&tools, &execution);
110
111        Ok(vec![ScanTarget {
112            name,
113            framework: Framework::GptActions,
114            root_path: root.to_path_buf(),
115            tools,
116            execution,
117            data,
118            dependencies,
119            provenance,
120            source_files,
121        }])
122    }
123}
124
125/// Check whether any plugin manifest file exists under root.
126fn has_plugin_manifest(root: &Path) -> bool {
127    for filename in PLUGIN_MANIFEST_FILENAMES {
128        if root.join(filename).exists() {
129            return true;
130        }
131    }
132    root.join(".well-known").join("ai-plugin.json").exists()
133}
134
135/// Find the first OpenAPI spec file present under root, in preference order.
136fn find_openapi_spec(root: &Path, filter: &ScanPathFilter) -> Option<PathBuf> {
137    for filename in OPENAPI_FILENAMES {
138        let path = root.join(filename);
139        if path.exists() && filter.allows_path(root, &path) {
140            return Some(path);
141        }
142    }
143    None
144}
145
146/// Extract server URLs from the OpenAPI `servers` array and emit them as
147/// `NetworkOperation` entries. This lets SSRF and data-exfiltration detectors
148/// inspect the domains the action contacts.
149fn extract_server_urls(
150    spec: &serde_json::Value,
151    spec_path: &Path,
152    execution: &mut ExecutionSurface,
153) {
154    let servers = match spec.get("servers").and_then(|v| v.as_array()) {
155        Some(s) => s,
156        None => return,
157    };
158
159    for (idx, server) in servers.iter().enumerate() {
160        let url = server
161            .get("url")
162            .and_then(|v| v.as_str())
163            .unwrap_or("")
164            .to_string();
165
166        if url.is_empty() {
167            continue;
168        }
169
170        execution.network_operations.push(NetworkOperation {
171            function: "openapi_server".to_string(),
172            url_arg: ArgumentSource::Literal(url),
173            method: None,
174            sends_data: false,
175            location: SourceLocation {
176                file: spec_path.to_path_buf(),
177                // Line numbers are not easily derivable from parsed JSON; use index as proxy
178                line: idx + 1,
179                column: 0,
180                end_line: None,
181                end_column: None,
182            },
183        });
184    }
185}
186
187/// Extract each OpenAPI path+method as a `ToolSurface`.
188///
189/// Name format: `{method}_{path}` (e.g. `get_/forecast`).
190/// Operation parameters are mapped to the input schema `properties`.
191fn extract_path_tools(spec: &serde_json::Value, spec_path: &Path, tools: &mut Vec<ToolSurface>) {
192    let paths = match spec.get("paths").and_then(|v| v.as_object()) {
193        Some(p) => p,
194        None => return,
195    };
196
197    const HTTP_METHODS: &[&str] = &["get", "post", "put", "patch", "delete", "head", "options"];
198
199    for (path_str, path_item) in paths {
200        let path_obj = match path_item.as_object() {
201            Some(o) => o,
202            None => continue,
203        };
204
205        for method in HTTP_METHODS {
206            let operation = match path_obj.get(*method) {
207                Some(op) => op,
208                None => continue,
209            };
210
211            let tool_name = format!("{}_{}", method, path_str);
212            let description = operation
213                .get("summary")
214                .or_else(|| operation.get("description"))
215                .and_then(|v| v.as_str())
216                .map(|s| s.to_string());
217
218            let input_schema = build_input_schema_from_operation(operation);
219
220            tools.push(ToolSurface {
221                name: tool_name,
222                description,
223                input_schema: Some(input_schema),
224                output_schema: None,
225                declared_permissions: vec![],
226                defined_at: Some(SourceLocation {
227                    file: spec_path.to_path_buf(),
228                    line: 1,
229                    column: 0,
230                    end_line: None,
231                    end_column: None,
232                }),
233            });
234        }
235    }
236}
237
238/// Build a JSON Schema `properties` object from the operation's `parameters`
239/// and `requestBody`, mirroring the shape expected by downstream detectors.
240fn build_input_schema_from_operation(operation: &serde_json::Value) -> serde_json::Value {
241    let mut properties = serde_json::Map::new();
242    let mut required: Vec<serde_json::Value> = Vec::new();
243
244    // Path / query / header parameters
245    if let Some(params) = operation.get("parameters").and_then(|v| v.as_array()) {
246        for param in params {
247            let name = match param.get("name").and_then(|v| v.as_str()) {
248                Some(n) => n,
249                None => continue,
250            };
251            let schema = param
252                .get("schema")
253                .cloned()
254                .unwrap_or_else(|| serde_json::json!({"type": "string"}));
255            properties.insert(name.to_string(), schema);
256
257            if param
258                .get("required")
259                .and_then(|v| v.as_bool())
260                .unwrap_or(false)
261            {
262                required.push(serde_json::Value::String(name.to_string()));
263            }
264        }
265    }
266
267    // requestBody (JSON only)
268    if let Some(rb_schema) = operation
269        .get("requestBody")
270        .and_then(|rb| rb.get("content"))
271        .and_then(|c| c.get("application/json"))
272        .and_then(|m| m.get("schema"))
273    {
274        if let Some(props) = rb_schema.get("properties").and_then(|v| v.as_object()) {
275            for (k, v) in props {
276                properties.insert(k.clone(), v.clone());
277            }
278        }
279        if let Some(req_arr) = rb_schema.get("required").and_then(|v| v.as_array()) {
280            required.extend(req_arr.iter().cloned());
281        }
282    }
283
284    let mut schema = serde_json::json!({
285        "type": "object",
286        "properties": serde_json::Value::Object(properties)
287    });
288    if !required.is_empty() {
289        schema["required"] = serde_json::Value::Array(required);
290    }
291    schema
292}
293
294/// Collect OpenAPI spec files and plugin manifests as `SourceFile` entries.
295///
296/// We do not parse them with language parsers (there is no Rust/Python source),
297/// but including them lets detectors and output formatters reference them.
298fn collect_spec_source_files(root: &Path, filter: &ScanPathFilter) -> Vec<SourceFile> {
299    let mut files = Vec::new();
300
301    let candidates: Vec<PathBuf> = OPENAPI_FILENAMES
302        .iter()
303        .chain(PLUGIN_MANIFEST_FILENAMES.iter())
304        .map(|f| root.join(f))
305        .chain(std::iter::once(
306            root.join(".well-known").join("ai-plugin.json"),
307        ))
308        .collect();
309
310    for path in candidates {
311        if !path.exists() {
312            continue;
313        }
314        if !filter.allows_path(root, &path) {
315            continue;
316        }
317        let Ok(metadata) = std::fs::metadata(&path) else {
318            continue;
319        };
320        let Ok(content) = std::fs::read_to_string(&path) else {
321            continue;
322        };
323
324        let ext = path
325            .extension()
326            .map(|e| e.to_string_lossy().to_string())
327            .unwrap_or_default();
328        let lang = Language::from_extension(&ext);
329
330        let hash = format!(
331            "{:x}",
332            sha2::Digest::finalize(sha2::Sha256::new().chain_update(content.as_bytes()))
333        );
334
335        files.push(SourceFile {
336            path,
337            language: lang,
338            size_bytes: metadata.len(),
339            content_hash: hash,
340            content,
341        });
342    }
343
344    files
345}
346
347use sha2::Digest;
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352    use crate::adapter::Adapter;
353
354    fn fixture_dir() -> PathBuf {
355        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/gpt_actions")
356    }
357
358    #[test]
359    fn test_detect_gpt_actions() {
360        let dir = fixture_dir();
361        let adapter = GptActionsAdapter;
362        assert!(
363            adapter.detect(&dir),
364            "should detect GPT Actions fixture with ai-plugin.json + openapi.json"
365        );
366    }
367
368    #[test]
369    fn test_detect_non_gpt_project() {
370        let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
371            .join("tests/fixtures/mcp_servers/safe_calculator");
372        let adapter = GptActionsAdapter;
373        assert!(
374            !adapter.detect(&dir),
375            "should not detect GPT Actions in an MCP calculator fixture"
376        );
377    }
378
379    #[test]
380    fn test_load_gpt_actions_tools() {
381        let dir = fixture_dir();
382        let adapter = GptActionsAdapter;
383        let targets = adapter.load(&dir, false).unwrap();
384        assert_eq!(targets.len(), 1);
385
386        let target = &targets[0];
387        assert_eq!(target.framework, Framework::GptActions);
388
389        // Fixture has /forecast (GET) and /alerts (GET) = 2 tools
390        assert!(
391            target.tools.len() >= 2,
392            "expected at least 2 tools from openapi.json paths, got {}",
393            target.tools.len()
394        );
395
396        // Tool names follow "{method}_{path}" format
397        let tool_names: Vec<&str> = target.tools.iter().map(|t| t.name.as_str()).collect();
398        assert!(
399            tool_names.contains(&"get_/forecast"),
400            "expected 'get_/forecast' tool"
401        );
402        assert!(
403            tool_names.contains(&"get_/alerts"),
404            "expected 'get_/alerts' tool"
405        );
406    }
407
408    #[test]
409    fn test_load_gpt_actions_input_schema() {
410        let dir = fixture_dir();
411        let adapter = GptActionsAdapter;
412        let targets = adapter.load(&dir, false).unwrap();
413        let target = &targets[0];
414
415        // /forecast has parameters: location (required), days (optional)
416        let forecast_tool = target
417            .tools
418            .iter()
419            .find(|t| t.name == "get_/forecast")
420            .expect("get_/forecast tool not found");
421
422        let schema = forecast_tool
423            .input_schema
424            .as_ref()
425            .expect("input_schema should be present");
426        let props = schema
427            .get("properties")
428            .and_then(|v| v.as_object())
429            .expect("properties should be an object");
430
431        assert!(
432            props.contains_key("location"),
433            "expected 'location' parameter"
434        );
435        assert!(props.contains_key("days"), "expected 'days' parameter");
436    }
437
438    #[test]
439    fn test_load_gpt_actions_network_operations() {
440        let dir = fixture_dir();
441        let adapter = GptActionsAdapter;
442        let targets = adapter.load(&dir, false).unwrap();
443        let target = &targets[0];
444
445        // openapi.json has servers: [{ url: "https://api.weather.example.com" }]
446        assert!(
447            !target.execution.network_operations.is_empty(),
448            "expected network operations from servers array"
449        );
450
451        let server_url = target
452            .execution
453            .network_operations
454            .iter()
455            .find(|op| matches!(&op.url_arg, ArgumentSource::Literal(u) if u.contains("weather.example.com")));
456        assert!(
457            server_url.is_some(),
458            "expected weather.example.com server URL"
459        );
460    }
461
462    #[test]
463    fn test_load_gpt_actions_source_files() {
464        let dir = fixture_dir();
465        let adapter = GptActionsAdapter;
466        let targets = adapter.load(&dir, false).unwrap();
467        let target = &targets[0];
468
469        // Should include openapi.json and ai-plugin.json
470        assert!(
471            !target.source_files.is_empty(),
472            "expected source files from fixture directory"
473        );
474
475        let file_names: Vec<String> = target
476            .source_files
477            .iter()
478            .map(|sf| {
479                sf.path
480                    .file_name()
481                    .unwrap_or_default()
482                    .to_string_lossy()
483                    .to_string()
484            })
485            .collect();
486
487        assert!(
488            file_names.contains(&"openapi.json".to_string()),
489            "expected openapi.json in source files"
490        );
491    }
492}