agentshield/adapter/gpt_actions/
mod.rs1pub 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
26pub 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 if has_plugin_manifest(root) {
44 return true;
45 }
46
47 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 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 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(&spec, &spec_path, &mut execution);
104
105 extract_path_tools(&spec, &spec_path, &mut tools);
107
108 auth::extract_security_schemes(&spec, &spec_path, &mut execution);
110 }
111 }
112
113 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 assert!(
218 target.tools.len() >= 2,
219 "expected at least 2 tools from openapi.json paths, got {}",
220 target.tools.len()
221 );
222
223 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 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 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 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}