1use agentguard_core::PolicyStore;
5use anyhow::{anyhow, Result};
6use serde::{Deserialize, Serialize};
7use std::str::FromStr;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10struct GenRequest {
11 model: String,
12 messages: Vec<ChatMsg>,
13 temperature: f32,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17struct ChatMsg {
18 role: String,
19 content: String,
20}
21
22const SYSTEM_PROMPT: &str = r#"You are an expert at writing Cedar authorization policies for AI agent systems.
23
24Output ONLY valid Cedar policy syntax. No commentary, no markdown fences.
25
26Rules:
271. Use `permit` for allow rules and `forbid` for deny rules.
282. Reference entities/types that exist in the provided schema.
293. Reference actions in the form `Action::"ToolCall::<tool>"`.
304. Use `when { ... }` and `unless { ... }` for conditions.
315. Use `principal in Group::"..."` or `principal == User::"..."` for principal constraints.
326. Keep policies minimal and composable.
337. Always output at least one policy."#;
34
35#[allow(clippy::too_many_arguments)]
36pub async fn run(
37 store: &str,
38 description: &str,
39 name: Option<&str>,
40 provider: &str,
41 model: &str,
42 dry_run: bool,
43 confirm: bool,
44 _output: &str,
45) -> Result<()> {
46 let store_path = store.to_string();
47 let schema_text = tokio::task::spawn_blocking(move || -> Result<(PolicyStore, String)> {
48 let s = PolicyStore::open(&store_path)?;
49 let text = std::fs::read_to_string(s.schema_path()).unwrap_or_default();
50 Ok((s, text))
51 })
52 .await
53 .map_err(|e| anyhow!("blocking task: {e}"))??;
54 let store = schema_text.0;
55 let schema_text = schema_text.1;
56
57 let user_prompt = format!(
58 "Schema:\n```cedarschema\n{}\n```\n\nRequirement:\n{}\n\nWrite 1-3 Cedar policies that implement this requirement.",
59 schema_text, description
60 );
61
62 let api_key = match provider {
63 "openai" => {
64 std::env::var("OPENAI_API_KEY").map_err(|_| anyhow!("set OPENAI_API_KEY env var"))?
65 }
66 "anthropic" => std::env::var("ANTHROPIC_API_KEY")
67 .map_err(|_| anyhow!("set ANTHROPIC_API_KEY env var"))?,
68 _ => return Err(anyhow!("unknown provider: {}", provider)),
69 };
70
71 let body = GenRequest {
72 model: model.to_string(),
73 messages: vec![
74 ChatMsg {
75 role: "system".into(),
76 content: SYSTEM_PROMPT.into(),
77 },
78 ChatMsg {
79 role: "user".into(),
80 content: user_prompt,
81 },
82 ],
83 temperature: 0.0,
84 };
85
86 let client = reqwest::Client::builder()
90 .timeout(std::time::Duration::from_secs(60))
91 .connect_timeout(std::time::Duration::from_secs(10))
92 .build()?;
93 let (url, auth_header) = match provider {
94 "openai" => (
95 "https://api.openai.com/v1/chat/completions",
96 format!("Bearer {}", api_key),
97 ),
98 "anthropic" => ("https://api.anthropic.com/v1/messages", api_key.clone()),
99 _ => unreachable!(),
100 };
101
102 let mut req = client.post(url).header("content-type", "application/json");
103 if provider == "anthropic" {
104 req = req
105 .header("x-api-key", auth_header)
106 .header("anthropic-version", "2023-06-01");
107 } else {
108 req = req.bearer_auth(auth_header);
109 }
110
111 let mut attempt = 0u32;
112 let resp = loop {
113 attempt += 1;
114 match req
115 .try_clone()
116 .expect("RequestBuilder is Clone-able")
117 .json(&body)
118 .send()
119 .await
120 {
121 Ok(r) if r.status().is_success() => break r,
122 Ok(r) if r.status().is_server_error() && attempt < 3 => {
123 let base_ms = 250u64 * (1u64 << (attempt - 1));
125 tokio::time::sleep(std::time::Duration::from_millis(base_ms)).await;
126 continue;
127 }
128 Ok(r) => {
129 let s = r.status();
130 let t = r.text().await.unwrap_or_default();
131 return Err(anyhow!(
132 "LLM API error {} after {} attempts: {}",
133 s,
134 attempt,
135 t
136 ));
137 }
138 Err(e) if e.is_timeout() || e.is_connect() && attempt < 3 => {
139 let base_ms = 250u64 * (1u64 << (attempt - 1));
140 tokio::time::sleep(std::time::Duration::from_millis(base_ms)).await;
141 continue;
142 }
143 Err(e) => {
144 return Err(anyhow!(
145 "LLM request failed after {} attempts: {}",
146 attempt,
147 e
148 ))
149 }
150 }
151 };
152
153 let json: serde_json::Value = resp.json().await?;
154 let content = extract_content(&json, provider)
155 .ok_or_else(|| anyhow!("could not parse LLM response: {}", json))?;
156
157 let cleaned = strip_fences(&content);
159
160 let policies = cedar_policy::PolicySet::from_str(&cleaned).map_err(|e| {
162 anyhow!(
163 "generated policies failed to parse: {}\n--- generated ---\n{}",
164 e,
165 cleaned
166 )
167 })?;
168 if let Ok(Some(s)) = store.load_schema() {
169 let validator = cedar_policy::Validator::new(s.schema);
170 let v = validator.validate(&policies, cedar_policy::ValidationMode::Strict);
171 if v.validation_errors().next().is_some() {
172 return Err(anyhow!(
173 "generated policies failed schema validation: {:?}",
174 v.validation_errors().collect::<Vec<_>>()
175 ));
176 }
177 }
178
179 if dry_run {
180 let fname = name.unwrap_or("generated");
184 println!("--- generated policy (dry run, would write to policies/{fname}.cedar) ---");
185 println!("{cleaned}");
186 return Ok(());
187 }
188 if confirm {
189 use tokio::io::{AsyncReadExt, AsyncWriteExt};
193 let mut stderr = tokio::io::stderr();
194 stderr
195 .write_all(b"Apply generated policy? [y/N] ")
196 .await
197 .ok();
198 let _ = stderr.flush().await;
199 let mut stdin = tokio::io::stdin();
200 let mut answer = [0u8; 1];
201 let n = stdin.read(&mut answer).await.unwrap_or(0);
202 if n == 0 || (answer[0] != b'y' && answer[0] != b'Y') {
203 eprintln!("aborted");
204 return Ok(());
205 }
206 }
207 let fname = name.unwrap_or("generated");
208 let path = store.write_policy(fname, &cleaned)?;
209 println!("wrote {}", path.display());
210 println!("--- generated policy ---");
211 println!("{}", cleaned);
212 Ok(())
213}
214
215fn extract_content(v: &serde_json::Value, provider: &str) -> Option<String> {
216 match provider {
217 "openai" => v
218 .get("choices")?
219 .get(0)?
220 .get("message")?
221 .get("content")?
222 .as_str()
223 .map(|s| s.to_string()),
224 "anthropic" => v
225 .get("content")?
226 .get(0)?
227 .get("text")?
228 .as_str()
229 .map(|s| s.to_string()),
230 _ => None,
231 }
232}
233
234fn strip_fences(s: &str) -> String {
235 let s = s.trim();
236 if let Some(rest) = s.strip_prefix("```cedar") {
237 if let Some(end) = rest.rfind("```") {
238 return rest[..end].trim().to_string();
239 }
240 }
241 if let Some(rest) = s.strip_prefix("```") {
242 if let Some(end) = rest.rfind("```") {
243 return rest[..end].trim().to_string();
244 }
245 }
246 s.to_string()
247}
248
249#[cfg(test)]
250mod tests {
251 use super::{extract_content, strip_fences};
252 use serde_json::json;
253
254 #[test]
255 fn extracts_openai_chat_completion_content() {
256 let response = json!({
257 "choices": [{"message": {"content": "permit (principal, action, resource);"}}]
258 });
259 assert_eq!(
260 extract_content(&response, "openai").as_deref(),
261 Some("permit (principal, action, resource);")
262 );
263 }
264
265 #[test]
266 fn extracts_anthropic_message_content() {
267 let response = json!({"content": [{"text": "permit (principal, action, resource);"}]});
268 assert_eq!(
269 extract_content(&response, "anthropic").as_deref(),
270 Some("permit (principal, action, resource);")
271 );
272 }
273
274 #[test]
275 fn rejects_unknown_or_malformed_provider_responses() {
276 let response = json!({"choices": []});
277 assert_eq!(extract_content(&response, "openai"), None);
278 assert_eq!(extract_content(&response, "unknown"), None);
279 }
280
281 #[test]
282 fn strips_language_and_generic_markdown_fences() {
283 assert_eq!(
284 strip_fences(" ```cedar\npermit (principal, action, resource);\n``` "),
285 "permit (principal, action, resource);"
286 );
287 assert_eq!(
288 strip_fences("```\npermit (principal, action, resource);\n```"),
289 "permit (principal, action, resource);"
290 );
291 }
292
293 #[test]
294 fn leaves_unfenced_and_unterminated_content_intact() {
295 assert_eq!(
296 strip_fences(" permit (principal, action, resource); "),
297 "permit (principal, action, resource);"
298 );
299 assert_eq!(strip_fences("```cedar\npermit"), "```cedar\npermit");
300 }
301}