Skip to main content

tryaudex_core/
intent.rs

1use serde::{Deserialize, Serialize};
2
3use crate::error::{AvError, Result};
4
5/// Parsed intent from natural language.
6#[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
60/// Parse a natural language intent into IAM permissions using the Claude API.
61pub 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    // Extract JSON from the response (handle potential markdown wrapping)
105    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    // Validate that actions look reasonable
123    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}