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