1use serde::{Deserialize, Serialize};
2
3use crate::error::{AvError, Result};
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct ParsedIntent {
8 pub allow: String,
9 pub resource: Option<String>,
10 pub description: String,
11}
12
13#[derive(Serialize)]
14struct ApiRequest {
15 model: String,
16 max_tokens: u32,
17 messages: Vec<Message>,
18 system: String,
19}
20
21#[derive(Serialize)]
22struct Message {
23 role: String,
24 content: String,
25}
26
27#[derive(Deserialize)]
28struct ApiResponse {
29 content: Vec<ContentBlock>,
30}
31
32#[derive(Deserialize)]
33struct ContentBlock {
34 text: Option<String>,
35}
36
37const SYSTEM_PROMPT: &str = r#"You are an AWS IAM policy expert. Given a natural language description of what a user wants to do, output the minimum IAM permissions needed.
38
39Respond with ONLY a JSON object (no markdown, no explanation) in this exact format:
40{
41 "allow": "service:Action,service:Action2",
42 "resource": "arn:aws:service:::resource/*" or null,
43 "description": "Brief description of what this policy allows"
44}
45
46Rules:
47- Use the minimum set of IAM actions needed
48- Use specific actions, not wildcards, unless truly needed
49- Include resource ARNs when the user mentions specific resources (buckets, tables, functions, etc.)
50- Use null for resource if it applies to all resources
51- For S3 bucket access, remember both the bucket ARN and bucket/* ARN
52- Action names must be valid IAM actions (e.g. s3:GetObject not s3:Get)
53- Common patterns:
54 - "read from S3" = s3:GetObject,s3:ListBucket
55 - "list S3 buckets" = s3:ListAllMyBuckets
56 - "deploy lambda" = lambda:UpdateFunctionCode,lambda:UpdateFunctionConfiguration,lambda:GetFunction
57 - "query dynamodb" = dynamodb:GetItem,dynamodb:Query,dynamodb:Scan
58 - "read logs" = logs:GetLogEvents,logs:DescribeLogGroups,logs:DescribeLogStreams,logs:FilterLogEvents"#;
59
60pub async fn parse(intent: &str, api_key: &str) -> Result<ParsedIntent> {
62 let client = reqwest::Client::new();
63
64 let request = ApiRequest {
65 model: "claude-haiku-4-5-20251001".to_string(),
66 max_tokens: 500,
67 system: SYSTEM_PROMPT.to_string(),
68 messages: vec![Message {
69 role: "user".to_string(),
70 content: intent.to_string(),
71 }],
72 };
73
74 let resp = client
75 .post("https://api.anthropic.com/v1/messages")
76 .header("x-api-key", api_key)
77 .header("anthropic-version", "2023-06-01")
78 .header("content-type", "application/json")
79 .json(&request)
80 .send()
81 .await
82 .map_err(|e| AvError::Sts(format!("Claude API request failed: {}", e)))?;
83
84 if !resp.status().is_success() {
85 let status = resp.status();
86 let body = resp.text().await.unwrap_or_default();
87 return Err(AvError::Sts(format!(
88 "Claude API error ({}): {}",
89 status, body
90 )));
91 }
92
93 let api_resp: ApiResponse = resp
94 .json()
95 .await
96 .map_err(|e| AvError::Sts(format!("Failed to parse Claude API response: {}", e)))?;
97
98 let text = api_resp
99 .content
100 .first()
101 .and_then(|b| b.text.as_ref())
102 .ok_or_else(|| AvError::Sts("Empty response from Claude API".to_string()))?;
103
104 let json_str = if let Some(start) = text.find('{') {
106 if let Some(end) = text.rfind('}') {
107 &text[start..=end]
108 } else {
109 text
110 }
111 } else {
112 text
113 };
114
115 let parsed: ParsedIntent = serde_json::from_str(json_str).map_err(|e| {
116 AvError::Sts(format!(
117 "Failed to parse Claude response as JSON: {}. Response was: {}",
118 e, text
119 ))
120 })?;
121
122 for action in parsed.allow.split(',') {
124 let action = action.trim();
125 if !action.contains(':') {
126 return Err(AvError::InvalidPolicy(format!(
127 "Claude returned invalid action '{}'. Expected format 'service:Action'",
128 action
129 )));
130 }
131 }
132
133 Ok(parsed)
134}