Skip to main content

everruns_core/capabilities/
fake_aws.rs

1//! Fake AWS Tools Capability - mock AWS infrastructure management
2//!
3//! All state is persisted in the session file system under `/aws/`.
4//! Every tool call simulates realistic AWS API latency (configurable via
5//! `FAKE_AWS_LATENCY_MS` env var; default 0 = no delay, set e.g. 200 for
6//! benchmarking long-running agents).
7//!
8//! Tools provided:
9//! - `aws_list_ec2_instances`: List EC2 instances
10//! - `aws_create_ec2_instance`: Launch a new EC2 instance
11//! - `aws_stop_ec2_instance`: Stop an EC2 instance
12//! - `aws_list_rds_databases`: List RDS database instances
13//! - `aws_create_rds_database`: Create a new RDS database
14//! - `aws_list_s3_buckets`: List S3 buckets
15//! - `aws_create_s3_bucket`: Create a new S3 bucket
16//! - `aws_list_iam_users`: List IAM users
17//! - `aws_create_iam_user`: Create a new IAM user
18//! - `aws_list_security_groups`: List security groups
19//! - `aws_get_cloudwatch_metrics`: Get CloudWatch metrics
20
21use super::{Capability, CapabilityLocalization, CapabilityStatus};
22use crate::SessionId;
23use crate::connector::{
24    Connector, ConnectorFormSchema, ConnectorPlugin, ConnectorType, ConnectorValidation, FormField,
25};
26use crate::tool_types::ToolHints;
27use crate::tools::{Tool, ToolExecutionResult};
28use crate::traits::{SessionFileSystem, ToolContext};
29use async_trait::async_trait;
30use serde::{Deserialize, Serialize};
31use serde_json::{Value, json};
32use std::sync::OnceLock;
33use std::time::Duration;
34
35inventory::submit! {
36    ConnectorPlugin {
37        experimental_only: true,
38        factory: || Box::new(FakeAwsConnector),
39    }
40}
41
42/// API-key style connection provider for the fake AWS tools.
43///
44/// The key is intentionally opaque to agents: it is captured through the normal
45/// Connections flow and resolved server-side by handoff gates or tools.
46pub struct FakeAwsConnector;
47
48#[async_trait]
49impl Connector for FakeAwsConnector {
50    fn provider_id(&self) -> &str {
51        "fake_aws"
52    }
53
54    fn display_name(&self) -> &str {
55        "Fake AWS"
56    }
57
58    fn description(&self) -> &str {
59        "Demo AWS-style connection for authenticated handoff examples."
60    }
61
62    fn icon(&self) -> &str {
63        "cloud"
64    }
65
66    fn connection_type(&self) -> ConnectorType {
67        ConnectorType::ApiKey
68    }
69
70    fn form_schema(&self) -> Option<ConnectorFormSchema> {
71        Some(ConnectorFormSchema {
72            fields: vec![
73                FormField::password("api_key", "Fake AWS API key")
74                    .required()
75                    .with_placeholder("fake_aws_...")
76                    .with_help("Any non-empty value is accepted for local fake AWS testing."),
77            ],
78            instructions_markdown:
79                "Use any non-empty value for local fake AWS handoff testing. Real providers should validate credentials against their upstream API."
80                    .to_string(),
81        })
82    }
83
84    async fn validate(&self, credential: &str) -> Result<ConnectorValidation, String> {
85        if credential.trim().is_empty() {
86            return Err("Fake AWS API key cannot be empty".to_string());
87        }
88        Ok(ConnectorValidation {
89            provider_username: Some("fake-aws-account".to_string()),
90            provider_metadata: Some(json!({
91                "account_alias": "fake-aws-account",
92                "provider": "fake_aws"
93            })),
94        })
95    }
96}
97
98// ============================================================================
99// Latency simulation
100// ============================================================================
101
102/// Base latency loaded once from `FAKE_AWS_LATENCY_MS` (default: 0).
103fn base_latency_ms() -> u64 {
104    static BASE: OnceLock<u64> = OnceLock::new();
105    *BASE.get_or_init(|| {
106        std::env::var("FAKE_AWS_LATENCY_MS")
107            .ok()
108            .and_then(|v| v.parse().ok())
109            .unwrap_or(0)
110    })
111}
112
113/// Operation-type multipliers applied to the base latency.
114#[derive(Clone, Copy)]
115enum OpKind {
116    /// List / describe operations (~1x base)
117    List,
118    /// Create / launch operations (~3x base)
119    Create,
120    /// Modify / stop operations (~2x base)
121    Modify,
122    /// Metrics / query operations (~1.5x base)
123    Query,
124}
125
126impl OpKind {
127    fn multiplier(self) -> f64 {
128        match self {
129            OpKind::List => 1.0,
130            OpKind::Create => 3.0,
131            OpKind::Modify => 2.0,
132            OpKind::Query => 1.5,
133        }
134    }
135}
136
137/// Deterministic jitter derived from current timestamp sub-micros (no rand dep).
138fn jitter_factor() -> f64 {
139    let nanos = chrono::Utc::now().timestamp_subsec_nanos();
140    // Produces a value in [0.8, 1.2)
141    0.8 + (nanos % 400) as f64 / 1000.0
142}
143
144/// Sleep to simulate AWS API latency. No-op when base is 0.
145async fn simulate_latency(op: OpKind) {
146    let base = base_latency_ms();
147    if base == 0 {
148        return;
149    }
150    let ms = (base as f64 * op.multiplier() * jitter_factor()) as u64;
151    tokio::time::sleep(Duration::from_millis(ms)).await;
152}
153
154// ============================================================================
155// Persistence helpers
156// ============================================================================
157
158/// Read a JSON collection from the session file store, returning seed data on
159/// first access and persisting it.
160async fn read_or_seed<T: Serialize + for<'de> Deserialize<'de>>(
161    store: &dyn SessionFileSystem,
162    session_id: SessionId,
163    path: &str,
164    seed: impl FnOnce() -> Vec<T>,
165) -> Vec<T> {
166    if let Ok(Some(file)) = store.read_file(session_id, path).await
167        && let Ok(items) = serde_json::from_str::<Vec<T>>(file.content.as_deref().unwrap_or(""))
168    {
169        return items;
170    }
171    // First access — persist seed data
172    let items = seed();
173    if let Ok(json) = serde_json::to_string_pretty(&items) {
174        let _ = store.write_file(session_id, path, &json, "text").await;
175    }
176    items
177}
178
179/// Persist a collection back to the session file store.
180async fn persist<T: Serialize>(
181    store: &dyn SessionFileSystem,
182    session_id: SessionId,
183    path: &str,
184    items: &[T],
185) -> Result<(), anyhow::Error> {
186    let json = serde_json::to_string_pretty(items)?;
187    store.write_file(session_id, path, &json, "text").await?;
188    Ok(())
189}
190
191use super::util::require_file_store;
192
193// ============================================================================
194// Data model structs
195// ============================================================================
196
197#[derive(Debug, Clone, Serialize, Deserialize)]
198struct Ec2Instance {
199    instance_id: String,
200    instance_type: String,
201    state: String,
202    availability_zone: String,
203    private_ip: String,
204    public_ip: Option<String>,
205    launch_time: String,
206    tags: Vec<Tag>,
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize)]
210struct Tag {
211    key: String,
212    value: String,
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize)]
216struct RdsDatabase {
217    db_instance_id: String,
218    engine: String,
219    engine_version: String,
220    instance_class: String,
221    status: String,
222    endpoint: String,
223    port: i32,
224    storage_gb: i32,
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize)]
228struct S3Bucket {
229    name: String,
230    region: String,
231    creation_date: String,
232    versioning_enabled: bool,
233    encryption_enabled: bool,
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize)]
237struct IamUser {
238    username: String,
239    user_id: String,
240    arn: String,
241    created_at: String,
242    permissions: Vec<String>,
243}
244
245#[derive(Debug, Clone, Serialize, Deserialize)]
246struct SecurityGroup {
247    group_id: String,
248    group_name: String,
249    description: String,
250    vpc_id: String,
251    inbound_rules: Vec<SecurityRule>,
252    outbound_rules: Vec<SecurityRule>,
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize)]
256struct SecurityRule {
257    protocol: String,
258    port_range: String,
259    source: String,
260}
261
262// ============================================================================
263// Seed data factories
264// ============================================================================
265
266const EC2_PATH: &str = "/aws/ec2_instances.json";
267const RDS_PATH: &str = "/aws/rds_databases.json";
268const S3_PATH: &str = "/aws/s3_buckets.json";
269const IAM_PATH: &str = "/aws/iam_users.json";
270const SG_PATH: &str = "/aws/security_groups.json";
271
272fn seed_ec2() -> Vec<Ec2Instance> {
273    vec![
274        // --- Healthy production instances ---
275        Ec2Instance {
276            instance_id: "i-0a1b2c3d4e5f60001".into(),
277            instance_type: "t3.medium".into(),
278            state: "running".into(),
279            availability_zone: "us-east-1a".into(),
280            private_ip: "10.0.1.10".into(),
281            public_ip: Some("54.200.10.1".into()),
282            launch_time: "2025-06-15T08:00:00Z".into(),
283            tags: vec![
284                Tag {
285                    key: "Name".into(),
286                    value: "web-server-01".into(),
287                },
288                Tag {
289                    key: "Environment".into(),
290                    value: "production".into(),
291                },
292                Tag {
293                    key: "Team".into(),
294                    value: "platform".into(),
295                },
296            ],
297        },
298        Ec2Instance {
299            instance_id: "i-0a1b2c3d4e5f60002".into(),
300            instance_type: "t3.medium".into(),
301            state: "running".into(),
302            availability_zone: "us-east-1b".into(),
303            private_ip: "10.0.2.10".into(),
304            public_ip: Some("54.200.10.2".into()),
305            launch_time: "2025-06-15T08:05:00Z".into(),
306            tags: vec![
307                Tag {
308                    key: "Name".into(),
309                    value: "web-server-02".into(),
310                },
311                Tag {
312                    key: "Environment".into(),
313                    value: "production".into(),
314                },
315                Tag {
316                    key: "Team".into(),
317                    value: "platform".into(),
318                },
319            ],
320        },
321        Ec2Instance {
322            instance_id: "i-0a1b2c3d4e5f60003".into(),
323            instance_type: "t3.large".into(),
324            state: "running".into(),
325            availability_zone: "us-east-1a".into(),
326            private_ip: "10.0.1.20".into(),
327            public_ip: Some("54.200.10.3".into()),
328            launch_time: "2025-07-01T10:00:00Z".into(),
329            tags: vec![
330                Tag {
331                    key: "Name".into(),
332                    value: "api-server-01".into(),
333                },
334                Tag {
335                    key: "Environment".into(),
336                    value: "production".into(),
337                },
338                Tag {
339                    key: "Team".into(),
340                    value: "backend".into(),
341                },
342            ],
343        },
344        Ec2Instance {
345            instance_id: "i-0a1b2c3d4e5f60004".into(),
346            instance_type: "t3.large".into(),
347            state: "running".into(),
348            availability_zone: "us-east-1b".into(),
349            private_ip: "10.0.2.20".into(),
350            public_ip: Some("54.200.10.4".into()),
351            launch_time: "2025-07-01T10:05:00Z".into(),
352            tags: vec![
353                Tag {
354                    key: "Name".into(),
355                    value: "api-server-02".into(),
356                },
357                Tag {
358                    key: "Environment".into(),
359                    value: "production".into(),
360                },
361                Tag {
362                    key: "Team".into(),
363                    value: "backend".into(),
364                },
365            ],
366        },
367        // --- ISSUE: Idle expensive instance (m5.xlarge barely doing anything) ---
368        Ec2Instance {
369            instance_id: "i-0a1b2c3d4e5f60005".into(),
370            instance_type: "m5.xlarge".into(),
371            state: "running".into(),
372            availability_zone: "us-east-1a".into(),
373            private_ip: "10.0.1.30".into(),
374            public_ip: Some("54.200.10.5".into()),
375            launch_time: "2025-03-10T09:00:00Z".into(),
376            tags: vec![
377                Tag {
378                    key: "Name".into(),
379                    value: "batch-processor-01".into(),
380                },
381                Tag {
382                    key: "Environment".into(),
383                    value: "production".into(),
384                },
385            ],
386        },
387        // --- ISSUE: Old-gen instance type (m4), missing Environment tag ---
388        Ec2Instance {
389            instance_id: "i-0a1b2c3d4e5f60006".into(),
390            instance_type: "m4.large".into(),
391            state: "running".into(),
392            availability_zone: "us-east-1a".into(),
393            private_ip: "10.0.1.40".into(),
394            public_ip: Some("54.200.10.6".into()),
395            launch_time: "2024-11-01T12:00:00Z".into(),
396            tags: vec![Tag {
397                key: "Name".into(),
398                value: "legacy-app-01".into(),
399            }],
400        },
401        // --- ISSUE: Dev instance running for months, wasting money ---
402        Ec2Instance {
403            instance_id: "i-0a1b2c3d4e5f60007".into(),
404            instance_type: "t3.small".into(),
405            state: "running".into(),
406            availability_zone: "us-east-1a".into(),
407            private_ip: "10.0.1.50".into(),
408            public_ip: Some("54.200.10.7".into()),
409            launch_time: "2025-01-15T14:00:00Z".into(),
410            tags: vec![
411                Tag {
412                    key: "Name".into(),
413                    value: "test-server-01".into(),
414                },
415                Tag {
416                    key: "Environment".into(),
417                    value: "development".into(),
418                },
419                Tag {
420                    key: "Owner".into(),
421                    value: "intern-temp".into(),
422                },
423            ],
424        },
425        // --- ISSUE: Stopped expensive instance, NO tags at all ---
426        Ec2Instance {
427            instance_id: "i-0a1b2c3d4e5f60008".into(),
428            instance_type: "c5.2xlarge".into(),
429            state: "stopped".into(),
430            availability_zone: "us-east-1b".into(),
431            private_ip: "10.0.2.60".into(),
432            public_ip: None,
433            launch_time: "2025-02-01T08:00:00Z".into(),
434            tags: vec![],
435        },
436    ]
437}
438
439fn seed_rds() -> Vec<RdsDatabase> {
440    vec![
441        // Healthy production database
442        RdsDatabase {
443            db_instance_id: "prod-postgres-01".into(),
444            engine: "postgres".into(),
445            engine_version: "15.4".into(),
446            instance_class: "db.t3.medium".into(),
447            status: "available".into(),
448            endpoint: "prod-postgres-01.abc123.us-east-1.rds.amazonaws.com".into(),
449            port: 5432,
450            storage_gb: 100,
451        },
452        // ISSUE: Oversized instance for what is actually a staging workload
453        RdsDatabase {
454            db_instance_id: "staging-mysql-01".into(),
455            engine: "mysql".into(),
456            engine_version: "8.0".into(),
457            instance_class: "db.r5.large".into(),
458            status: "available".into(),
459            endpoint: "staging-mysql-01.abc123.us-east-1.rds.amazonaws.com".into(),
460            port: 3306,
461            storage_gb: 500,
462        },
463        // ISSUE: End-of-life Postgres 11 (EOL Nov 2023)
464        RdsDatabase {
465            db_instance_id: "legacy-pg-11".into(),
466            engine: "postgres".into(),
467            engine_version: "11.22".into(),
468            instance_class: "db.t3.small".into(),
469            status: "available".into(),
470            endpoint: "legacy-pg-11.abc123.us-east-1.rds.amazonaws.com".into(),
471            port: 5432,
472            storage_gb: 20,
473        },
474    ]
475}
476
477fn seed_s3() -> Vec<S3Bucket> {
478    vec![
479        // Healthy: properly configured backup bucket
480        S3Bucket {
481            name: "company-data-backup".into(),
482            region: "us-east-1".into(),
483            creation_date: "2024-06-01T00:00:00Z".into(),
484            versioning_enabled: true,
485            encryption_enabled: true,
486        },
487        // ISSUE: Production assets without versioning
488        S3Bucket {
489            name: "static-assets-prod".into(),
490            region: "us-east-1".into(),
491            creation_date: "2024-08-15T00:00:00Z".into(),
492            versioning_enabled: false,
493            encryption_enabled: true,
494        },
495        // ISSUE: No encryption AND no versioning — dump bucket
496        S3Bucket {
497            name: "temp-data-dump".into(),
498            region: "us-east-1".into(),
499            creation_date: "2025-04-01T00:00:00Z".into(),
500            versioning_enabled: false,
501            encryption_enabled: false,
502        },
503        // CRITICAL: PII bucket without encryption!
504        S3Bucket {
505            name: "customer-pii-records".into(),
506            region: "us-east-1".into(),
507            creation_date: "2024-09-10T00:00:00Z".into(),
508            versioning_enabled: true,
509            encryption_enabled: false,
510        },
511        // ISSUE: Stale dev bucket, no encryption, no versioning
512        S3Bucket {
513            name: "dev-test-artifacts".into(),
514            region: "us-west-2".into(),
515            creation_date: "2025-01-20T00:00:00Z".into(),
516            versioning_enabled: false,
517            encryption_enabled: false,
518        },
519        // Healthy: well-configured archive
520        S3Bucket {
521            name: "log-archive-2024".into(),
522            region: "us-east-1".into(),
523            creation_date: "2024-01-01T00:00:00Z".into(),
524            versioning_enabled: true,
525            encryption_enabled: true,
526        },
527    ]
528}
529
530fn seed_iam() -> Vec<IamUser> {
531    vec![
532        // ISSUE: Human user with full admin — should use roles instead
533        IamUser {
534            username: "admin-user".into(),
535            user_id: "AIDAI23XYZABC123DEF".into(),
536            arn: "arn:aws:iam::123456789012:user/admin-user".into(),
537            created_at: "2024-01-15T10:00:00Z".into(),
538            permissions: vec!["AdministratorAccess".into()],
539        },
540        // ISSUE: Overly broad PowerUserAccess
541        IamUser {
542            username: "developer-user".into(),
543            user_id: "AIDAI23XYZABC456GHI".into(),
544            arn: "arn:aws:iam::123456789012:user/developer-user".into(),
545            created_at: "2024-02-20T14:30:00Z".into(),
546            permissions: vec!["PowerUserAccess".into()],
547        },
548        // CRITICAL: CI bot with AdministratorAccess — massive risk
549        IamUser {
550            username: "ci-deploy-bot".into(),
551            user_id: "AIDAI23XYZABC789JKL".into(),
552            arn: "arn:aws:iam::123456789012:user/ci-deploy-bot".into(),
553            created_at: "2024-06-01T09:00:00Z".into(),
554            permissions: vec!["AdministratorAccess".into(), "IAMFullAccess".into()],
555        },
556        // ISSUE: Temp intern account with excessive permissions
557        IamUser {
558            username: "intern-temp".into(),
559            user_id: "AIDAI23XYZABCMNOPQR".into(),
560            arn: "arn:aws:iam::123456789012:user/intern-temp".into(),
561            created_at: "2025-06-01T10:00:00Z".into(),
562            permissions: vec![
563                "AmazonS3FullAccess".into(),
564                "AmazonEC2FullAccess".into(),
565                "AmazonRDSFullAccess".into(),
566            ],
567        },
568        // Healthy: monitoring service with read-only access
569        IamUser {
570            username: "monitoring-svc".into(),
571            user_id: "AIDAI23XYZABCSTUVWX".into(),
572            arn: "arn:aws:iam::123456789012:user/monitoring-svc".into(),
573            created_at: "2024-03-01T08:00:00Z".into(),
574            permissions: vec!["CloudWatchReadOnlyAccess".into()],
575        },
576        // Mostly OK: analyst with appropriately scoped access
577        IamUser {
578            username: "data-analyst".into(),
579            user_id: "AIDAI23XYZABCYZABCD".into(),
580            arn: "arn:aws:iam::123456789012:user/data-analyst".into(),
581            created_at: "2024-08-15T11:00:00Z".into(),
582            permissions: vec![
583                "AmazonS3ReadOnlyAccess".into(),
584                "AmazonAthenaFullAccess".into(),
585            ],
586        },
587    ]
588}
589
590fn seed_security_groups() -> Vec<SecurityGroup> {
591    vec![
592        // Mostly OK: standard web-facing security group
593        SecurityGroup {
594            group_id: "sg-0a1b2c3d4e5f60001".into(),
595            group_name: "web-server-sg".into(),
596            description: "Security group for web servers".into(),
597            vpc_id: "vpc-abc123".into(),
598            inbound_rules: vec![
599                SecurityRule {
600                    protocol: "tcp".into(),
601                    port_range: "80".into(),
602                    source: "0.0.0.0/0".into(),
603                },
604                SecurityRule {
605                    protocol: "tcp".into(),
606                    port_range: "443".into(),
607                    source: "0.0.0.0/0".into(),
608                },
609            ],
610            outbound_rules: vec![SecurityRule {
611                protocol: "-1".into(),
612                port_range: "all".into(),
613                source: "0.0.0.0/0".into(),
614            }],
615        },
616        // CRITICAL: Database ports open to the entire internet!
617        SecurityGroup {
618            group_id: "sg-0a1b2c3d4e5f60002".into(),
619            group_name: "database-sg".into(),
620            description: "Security group for databases".into(),
621            vpc_id: "vpc-abc123".into(),
622            inbound_rules: vec![
623                SecurityRule {
624                    protocol: "tcp".into(),
625                    port_range: "5432".into(),
626                    source: "0.0.0.0/0".into(),
627                },
628                SecurityRule {
629                    protocol: "tcp".into(),
630                    port_range: "3306".into(),
631                    source: "0.0.0.0/0".into(),
632                },
633            ],
634            outbound_rules: vec![SecurityRule {
635                protocol: "-1".into(),
636                port_range: "all".into(),
637                source: "0.0.0.0/0".into(),
638            }],
639        },
640        // CRITICAL: SSH and RDP open to the world
641        SecurityGroup {
642            group_id: "sg-0a1b2c3d4e5f60003".into(),
643            group_name: "admin-access-sg".into(),
644            description: "Security group for admin access".into(),
645            vpc_id: "vpc-abc123".into(),
646            inbound_rules: vec![
647                SecurityRule {
648                    protocol: "tcp".into(),
649                    port_range: "22".into(),
650                    source: "0.0.0.0/0".into(),
651                },
652                SecurityRule {
653                    protocol: "tcp".into(),
654                    port_range: "3389".into(),
655                    source: "0.0.0.0/0".into(),
656                },
657            ],
658            outbound_rules: vec![SecurityRule {
659                protocol: "-1".into(),
660                port_range: "all".into(),
661                source: "0.0.0.0/0".into(),
662            }],
663        },
664    ]
665}
666
667// ============================================================================
668// CloudWatch metric profiles for seed resources
669// ============================================================================
670
671/// Per-resource metric base values. Overrides the generic default so seed
672/// instances tell a realistic story the auditor can discover.
673fn metric_profile_base(resource_id: &str, metric: &str) -> Option<f64> {
674    match (resource_id, metric) {
675        // web-server-01: healthy, moderate-high load
676        ("i-0a1b2c3d4e5f60001", "CPUUtilization") => Some(62.0),
677        ("i-0a1b2c3d4e5f60001", "MemoryUtilization") => Some(71.0),
678        ("i-0a1b2c3d4e5f60001", "NetworkIn") => Some(85_000.0),
679
680        // web-server-02: healthy, moderate load
681        ("i-0a1b2c3d4e5f60002", "CPUUtilization") => Some(48.0),
682        ("i-0a1b2c3d4e5f60002", "MemoryUtilization") => Some(55.0),
683        ("i-0a1b2c3d4e5f60002", "NetworkIn") => Some(72_000.0),
684
685        // api-server-01: healthy, moderate load
686        ("i-0a1b2c3d4e5f60003", "CPUUtilization") => Some(45.0),
687        ("i-0a1b2c3d4e5f60003", "MemoryUtilization") => Some(60.0),
688        ("i-0a1b2c3d4e5f60003", "NetworkIn") => Some(95_000.0),
689
690        // api-server-02: healthy, moderate load
691        ("i-0a1b2c3d4e5f60004", "CPUUtilization") => Some(42.0),
692        ("i-0a1b2c3d4e5f60004", "MemoryUtilization") => Some(58.0),
693        ("i-0a1b2c3d4e5f60004", "NetworkIn") => Some(88_000.0),
694
695        // batch-processor-01: IDLE — nearly zero utilization on expensive m5.xlarge
696        ("i-0a1b2c3d4e5f60005", "CPUUtilization") => Some(2.1),
697        ("i-0a1b2c3d4e5f60005", "MemoryUtilization") => Some(5.3),
698        ("i-0a1b2c3d4e5f60005", "NetworkIn") => Some(320.0),
699
700        // legacy-app-01: very low utilization on old-gen m4
701        ("i-0a1b2c3d4e5f60006", "CPUUtilization") => Some(4.8),
702        ("i-0a1b2c3d4e5f60006", "MemoryUtilization") => Some(12.0),
703        ("i-0a1b2c3d4e5f60006", "NetworkIn") => Some(1_200.0),
704
705        // test-server-01: minimal dev traffic
706        ("i-0a1b2c3d4e5f60007", "CPUUtilization") => Some(3.2),
707        ("i-0a1b2c3d4e5f60007", "MemoryUtilization") => Some(9.0),
708        ("i-0a1b2c3d4e5f60007", "NetworkIn") => Some(800.0),
709
710        // prod-postgres-01: healthy RDS
711        ("prod-postgres-01", "CPUUtilization") => Some(38.0),
712        ("prod-postgres-01", "MemoryUtilization") => Some(65.0),
713
714        // staging-mysql-01: underutilized oversized RDS
715        ("staging-mysql-01", "CPUUtilization") => Some(8.5),
716        ("staging-mysql-01", "MemoryUtilization") => Some(15.0),
717
718        // legacy-pg-11: EOL database, low load
719        ("legacy-pg-11", "CPUUtilization") => Some(6.0),
720        ("legacy-pg-11", "MemoryUtilization") => Some(22.0),
721
722        _ => None,
723    }
724}
725
726// ============================================================================
727// Capability
728// ============================================================================
729
730pub const FAKE_AWS_CAPABILITY_ID: &str = "fake_aws";
731
732pub struct FakeAwsCapability;
733
734impl Capability for FakeAwsCapability {
735    fn id(&self) -> &str {
736        FAKE_AWS_CAPABILITY_ID
737    }
738
739    fn name(&self) -> &str {
740        "Fake AWS Tools"
741    }
742
743    fn description(&self) -> &str {
744        "Mock AWS infrastructure tools (EC2, RDS, S3, IAM, CloudWatch). \
745         All state persisted in session filesystem. Latency emulation via FAKE_AWS_LATENCY_MS."
746    }
747
748    fn localizations(&self) -> Vec<CapabilityLocalization> {
749        vec![CapabilityLocalization::text(
750            "uk",
751            "Демо-інструменти AWS",
752            "Імітаційні інструменти інфраструктури AWS (EC2, RDS, S3, IAM, CloudWatch). Увесь стан зберігається у файловій системі сесії. Емуляція затримки через FAKE_AWS_LATENCY_MS.",
753        )]
754    }
755
756    fn status(&self) -> CapabilityStatus {
757        CapabilityStatus::Available
758    }
759
760    fn icon(&self) -> Option<&str> {
761        Some("cloud")
762    }
763
764    fn category(&self) -> Option<&str> {
765        Some("Demo Tools")
766    }
767
768    fn system_prompt_addition(&self) -> Option<&str> {
769        Some(
770            "AWS data is stored in /aws/ (ec2_instances.json, rds_databases.json, s3_buckets.json, iam_users.json, security_groups.json). API calls have realistic latency.",
771        )
772    }
773
774    fn tools(&self) -> Vec<Box<dyn Tool>> {
775        vec![
776            Box::new(AwsListEc2InstancesTool),
777            Box::new(AwsCreateEc2InstanceTool),
778            Box::new(AwsStopEc2InstanceTool),
779            Box::new(AwsListRdsDatabasesTool),
780            Box::new(AwsCreateRdsDatabaseTool),
781            Box::new(AwsListS3BucketsTool),
782            Box::new(AwsCreateS3BucketTool),
783            Box::new(AwsListIamUsersTool),
784            Box::new(AwsCreateIamUserTool),
785            Box::new(AwsListSecurityGroupsTool),
786            Box::new(AwsGetCloudWatchMetricsTool),
787        ]
788    }
789}
790
791// ============================================================================
792// Tool: aws_list_ec2_instances
793// ============================================================================
794
795pub struct AwsListEc2InstancesTool;
796
797#[async_trait]
798impl Tool for AwsListEc2InstancesTool {
799    fn name(&self) -> &str {
800        "aws_list_ec2_instances"
801    }
802
803    fn display_name(&self) -> Option<&str> {
804        Some("List EC2 Instances")
805    }
806
807    fn description(&self) -> &str {
808        "List all EC2 instances with their current status, IPs, and configuration."
809    }
810
811    fn parameters_schema(&self) -> Value {
812        json!({
813            "type": "object",
814            "properties": {
815                "state": {
816                    "type": "string",
817                    "enum": ["running", "stopped", "terminated"],
818                    "description": "Optional: Filter by instance state"
819                }
820            },
821            "additionalProperties": false
822        })
823    }
824
825    fn hints(&self) -> ToolHints {
826        ToolHints::default()
827            .with_readonly(true)
828            .with_idempotent(true)
829    }
830
831    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
832        ToolExecutionResult::tool_error("aws_list_ec2_instances requires context")
833    }
834
835    async fn execute_with_context(
836        &self,
837        arguments: Value,
838        context: &ToolContext,
839    ) -> ToolExecutionResult {
840        let store = match require_file_store(context) {
841            Ok(store) => store,
842            Err(e) => return e,
843        };
844        simulate_latency(OpKind::List).await;
845
846        let state_filter = arguments.get("state").and_then(|v| v.as_str());
847        let instances = read_or_seed(store, context.session_id, EC2_PATH, seed_ec2).await;
848
849        let filtered: Vec<_> = if let Some(state) = state_filter {
850            instances.into_iter().filter(|i| i.state == state).collect()
851        } else {
852            instances
853        };
854
855        ToolExecutionResult::success(json!({
856            "instances": filtered,
857            "total_count": filtered.len()
858        }))
859    }
860
861    fn requires_context(&self) -> bool {
862        true
863    }
864}
865
866// ============================================================================
867// Tool: aws_create_ec2_instance
868// ============================================================================
869
870pub struct AwsCreateEc2InstanceTool;
871
872#[async_trait]
873impl Tool for AwsCreateEc2InstanceTool {
874    fn name(&self) -> &str {
875        "aws_create_ec2_instance"
876    }
877
878    fn display_name(&self) -> Option<&str> {
879        Some("Create EC2 Instance")
880    }
881
882    fn description(&self) -> &str {
883        "Launch a new EC2 instance with specified configuration."
884    }
885
886    fn parameters_schema(&self) -> Value {
887        json!({
888            "type": "object",
889            "properties": {
890                "instance_type": {
891                    "type": "string",
892                    "description": "Instance type (e.g., 't3.micro', 't3.medium')"
893                },
894                "name": {
895                    "type": "string",
896                    "description": "Instance name tag"
897                },
898                "availability_zone": {
899                    "type": "string",
900                    "description": "Availability zone (default: us-east-1a)"
901                }
902            },
903            "required": ["instance_type", "name"],
904            "additionalProperties": false
905        })
906    }
907
908    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
909        ToolExecutionResult::tool_error("aws_create_ec2_instance requires context")
910    }
911
912    async fn execute_with_context(
913        &self,
914        arguments: Value,
915        context: &ToolContext,
916    ) -> ToolExecutionResult {
917        let store = match require_file_store(context) {
918            Ok(store) => store,
919            Err(e) => return e,
920        };
921        simulate_latency(OpKind::Create).await;
922
923        let instance_type = match arguments.get("instance_type").and_then(|v| v.as_str()) {
924            Some(t) => t,
925            None => {
926                return ToolExecutionResult::tool_error(
927                    "Missing required parameter: instance_type",
928                );
929            }
930        };
931        let name = match arguments.get("name").and_then(|v| v.as_str()) {
932            Some(n) => n,
933            None => return ToolExecutionResult::tool_error("Missing required parameter: name"),
934        };
935        let az = arguments
936            .get("availability_zone")
937            .and_then(|v| v.as_str())
938            .unwrap_or("us-east-1a");
939
940        let mut instances = read_or_seed(store, context.session_id, EC2_PATH, seed_ec2).await;
941
942        let instance_id = format!(
943            "i-{:016x}",
944            chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0) as u64
945        );
946        let private_ip = format!("10.0.{}.{}", (instances.len() % 254) + 1, 100);
947        let public_ip = format!(
948            "54.{}.{}.{}",
949            100 + instances.len() % 100,
950            (instances.len() % 254) + 1,
951            100
952        );
953
954        let instance = Ec2Instance {
955            instance_id: instance_id.clone(),
956            instance_type: instance_type.to_string(),
957            state: "running".into(),
958            availability_zone: az.to_string(),
959            private_ip: private_ip.clone(),
960            public_ip: Some(public_ip.clone()),
961            launch_time: chrono::Utc::now().to_rfc3339(),
962            tags: vec![Tag {
963                key: "Name".into(),
964                value: name.to_string(),
965            }],
966        };
967        instances.push(instance);
968
969        match persist(store, context.session_id, EC2_PATH, &instances).await {
970            Ok(_) => ToolExecutionResult::success(json!({
971                "instance_id": instance_id,
972                "state": "running",
973                "private_ip": private_ip,
974                "public_ip": public_ip,
975                "message": "EC2 instance launched successfully"
976            })),
977            Err(e) => ToolExecutionResult::tool_error(format!("Failed to persist: {e}")),
978        }
979    }
980
981    fn requires_context(&self) -> bool {
982        true
983    }
984}
985
986// ============================================================================
987// Tool: aws_stop_ec2_instance
988// ============================================================================
989
990pub struct AwsStopEc2InstanceTool;
991
992#[async_trait]
993impl Tool for AwsStopEc2InstanceTool {
994    fn name(&self) -> &str {
995        "aws_stop_ec2_instance"
996    }
997
998    fn display_name(&self) -> Option<&str> {
999        Some("Stop EC2 Instance")
1000    }
1001
1002    fn description(&self) -> &str {
1003        "Stop a running EC2 instance."
1004    }
1005
1006    fn parameters_schema(&self) -> Value {
1007        json!({
1008            "type": "object",
1009            "properties": {
1010                "instance_id": {
1011                    "type": "string",
1012                    "description": "Instance ID to stop"
1013                }
1014            },
1015            "required": ["instance_id"],
1016            "additionalProperties": false
1017        })
1018    }
1019
1020    fn hints(&self) -> ToolHints {
1021        ToolHints::default().with_destructive(true)
1022    }
1023
1024    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
1025        ToolExecutionResult::tool_error("aws_stop_ec2_instance requires context")
1026    }
1027
1028    async fn execute_with_context(
1029        &self,
1030        arguments: Value,
1031        context: &ToolContext,
1032    ) -> ToolExecutionResult {
1033        let store = match require_file_store(context) {
1034            Ok(store) => store,
1035            Err(e) => return e,
1036        };
1037        simulate_latency(OpKind::Modify).await;
1038
1039        let instance_id = match arguments.get("instance_id").and_then(|v| v.as_str()) {
1040            Some(id) => id,
1041            None => {
1042                return ToolExecutionResult::tool_error("Missing required parameter: instance_id");
1043            }
1044        };
1045
1046        let mut instances = read_or_seed(store, context.session_id, EC2_PATH, seed_ec2).await;
1047
1048        let instance = match instances.iter_mut().find(|i| i.instance_id == instance_id) {
1049            Some(i) => i,
1050            None => {
1051                return ToolExecutionResult::tool_error(format!(
1052                    "Instance not found: {}",
1053                    instance_id
1054                ));
1055            }
1056        };
1057
1058        let old_state = instance.state.clone();
1059        instance.state = "stopped".into();
1060
1061        match persist(store, context.session_id, EC2_PATH, &instances).await {
1062            Ok(_) => ToolExecutionResult::success(json!({
1063                "instance_id": instance_id,
1064                "old_state": old_state,
1065                "new_state": "stopped"
1066            })),
1067            Err(e) => ToolExecutionResult::tool_error(format!("Failed to persist: {e}")),
1068        }
1069    }
1070
1071    fn requires_context(&self) -> bool {
1072        true
1073    }
1074}
1075
1076// ============================================================================
1077// Tool: aws_list_rds_databases
1078// ============================================================================
1079
1080pub struct AwsListRdsDatabasesTool;
1081
1082#[async_trait]
1083impl Tool for AwsListRdsDatabasesTool {
1084    fn name(&self) -> &str {
1085        "aws_list_rds_databases"
1086    }
1087
1088    fn display_name(&self) -> Option<&str> {
1089        Some("List RDS Databases")
1090    }
1091
1092    fn description(&self) -> &str {
1093        "List all RDS database instances."
1094    }
1095
1096    fn parameters_schema(&self) -> Value {
1097        json!({"type": "object", "properties": {}, "additionalProperties": false})
1098    }
1099
1100    fn hints(&self) -> ToolHints {
1101        ToolHints::default()
1102            .with_readonly(true)
1103            .with_idempotent(true)
1104    }
1105
1106    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
1107        ToolExecutionResult::tool_error("Requires context")
1108    }
1109
1110    async fn execute_with_context(
1111        &self,
1112        _arguments: Value,
1113        context: &ToolContext,
1114    ) -> ToolExecutionResult {
1115        let store = match require_file_store(context) {
1116            Ok(store) => store,
1117            Err(e) => return e,
1118        };
1119        simulate_latency(OpKind::List).await;
1120
1121        let databases = read_or_seed(store, context.session_id, RDS_PATH, seed_rds).await;
1122
1123        ToolExecutionResult::success(
1124            json!({"databases": databases, "total_count": databases.len()}),
1125        )
1126    }
1127
1128    fn requires_context(&self) -> bool {
1129        true
1130    }
1131}
1132
1133// ============================================================================
1134// Tool: aws_create_rds_database
1135// ============================================================================
1136
1137pub struct AwsCreateRdsDatabaseTool;
1138
1139#[async_trait]
1140impl Tool for AwsCreateRdsDatabaseTool {
1141    fn name(&self) -> &str {
1142        "aws_create_rds_database"
1143    }
1144
1145    fn display_name(&self) -> Option<&str> {
1146        Some("Create RDS Database")
1147    }
1148
1149    fn description(&self) -> &str {
1150        "Create a new RDS database instance."
1151    }
1152
1153    fn parameters_schema(&self) -> Value {
1154        json!({
1155            "type": "object",
1156            "properties": {
1157                "db_instance_id": {"type": "string"},
1158                "engine": {"type": "string", "enum": ["postgres", "mysql", "mariadb"]},
1159                "instance_class": {"type": "string"},
1160                "storage_gb": {"type": "integer"}
1161            },
1162            "required": ["db_instance_id", "engine", "instance_class"],
1163            "additionalProperties": false
1164        })
1165    }
1166
1167    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
1168        ToolExecutionResult::tool_error("Requires context")
1169    }
1170
1171    async fn execute_with_context(
1172        &self,
1173        arguments: Value,
1174        context: &ToolContext,
1175    ) -> ToolExecutionResult {
1176        let store = match require_file_store(context) {
1177            Ok(store) => store,
1178            Err(e) => return e,
1179        };
1180        simulate_latency(OpKind::Create).await;
1181
1182        let db_id = match arguments.get("db_instance_id").and_then(|v| v.as_str()) {
1183            Some(id) => id,
1184            None => {
1185                return ToolExecutionResult::tool_error(
1186                    "Missing required parameter: db_instance_id",
1187                );
1188            }
1189        };
1190        let engine = match arguments.get("engine").and_then(|v| v.as_str()) {
1191            Some(e) => e,
1192            None => return ToolExecutionResult::tool_error("Missing required parameter: engine"),
1193        };
1194        let instance_class = match arguments.get("instance_class").and_then(|v| v.as_str()) {
1195            Some(c) => c,
1196            None => {
1197                return ToolExecutionResult::tool_error(
1198                    "Missing required parameter: instance_class",
1199                );
1200            }
1201        };
1202        let storage_gb = arguments
1203            .get("storage_gb")
1204            .and_then(|v| v.as_i64())
1205            .unwrap_or(20) as i32;
1206
1207        let engine_version = match engine {
1208            "postgres" => "15.4",
1209            "mysql" => "8.0",
1210            "mariadb" => "10.11",
1211            _ => "unknown",
1212        };
1213        let port = match engine {
1214            "postgres" => 5432,
1215            "mysql" | "mariadb" => 3306,
1216            _ => 5432,
1217        };
1218
1219        let mut databases = read_or_seed(store, context.session_id, RDS_PATH, seed_rds).await;
1220
1221        let db = RdsDatabase {
1222            db_instance_id: db_id.to_string(),
1223            engine: engine.to_string(),
1224            engine_version: engine_version.to_string(),
1225            instance_class: instance_class.to_string(),
1226            status: "creating".into(),
1227            endpoint: format!("{}.abc123.us-east-1.rds.amazonaws.com", db_id),
1228            port,
1229            storage_gb,
1230        };
1231        databases.push(db);
1232
1233        match persist(store, context.session_id, RDS_PATH, &databases).await {
1234            Ok(_) => ToolExecutionResult::success(json!({
1235                "db_instance_id": db_id,
1236                "status": "creating",
1237                "endpoint": format!("{}.abc123.us-east-1.rds.amazonaws.com", db_id),
1238                "message": "RDS database creation initiated"
1239            })),
1240            Err(e) => ToolExecutionResult::tool_error(format!("Failed to persist: {e}")),
1241        }
1242    }
1243
1244    fn requires_context(&self) -> bool {
1245        true
1246    }
1247}
1248
1249// ============================================================================
1250// Tool: aws_list_s3_buckets
1251// ============================================================================
1252
1253pub struct AwsListS3BucketsTool;
1254
1255#[async_trait]
1256impl Tool for AwsListS3BucketsTool {
1257    fn name(&self) -> &str {
1258        "aws_list_s3_buckets"
1259    }
1260
1261    fn display_name(&self) -> Option<&str> {
1262        Some("List S3 Buckets")
1263    }
1264
1265    fn description(&self) -> &str {
1266        "List all S3 buckets in the account."
1267    }
1268
1269    fn parameters_schema(&self) -> Value {
1270        json!({"type": "object", "properties": {}, "additionalProperties": false})
1271    }
1272
1273    fn hints(&self) -> ToolHints {
1274        ToolHints::default()
1275            .with_readonly(true)
1276            .with_idempotent(true)
1277    }
1278
1279    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
1280        ToolExecutionResult::tool_error("Requires context")
1281    }
1282
1283    async fn execute_with_context(
1284        &self,
1285        _arguments: Value,
1286        context: &ToolContext,
1287    ) -> ToolExecutionResult {
1288        let store = match require_file_store(context) {
1289            Ok(store) => store,
1290            Err(e) => return e,
1291        };
1292        simulate_latency(OpKind::List).await;
1293
1294        let buckets = read_or_seed(store, context.session_id, S3_PATH, seed_s3).await;
1295
1296        ToolExecutionResult::success(json!({"buckets": buckets, "total_count": buckets.len()}))
1297    }
1298
1299    fn requires_context(&self) -> bool {
1300        true
1301    }
1302}
1303
1304// ============================================================================
1305// Tool: aws_create_s3_bucket
1306// ============================================================================
1307
1308pub struct AwsCreateS3BucketTool;
1309
1310#[async_trait]
1311impl Tool for AwsCreateS3BucketTool {
1312    fn name(&self) -> &str {
1313        "aws_create_s3_bucket"
1314    }
1315
1316    fn display_name(&self) -> Option<&str> {
1317        Some("Create S3 Bucket")
1318    }
1319
1320    fn description(&self) -> &str {
1321        "Create a new S3 bucket."
1322    }
1323
1324    fn parameters_schema(&self) -> Value {
1325        json!({
1326            "type": "object",
1327            "properties": {
1328                "bucket_name": {"type": "string"},
1329                "region": {"type": "string"},
1330                "versioning_enabled": {"type": "boolean"},
1331                "encryption_enabled": {"type": "boolean"}
1332            },
1333            "required": ["bucket_name"],
1334            "additionalProperties": false
1335        })
1336    }
1337
1338    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
1339        ToolExecutionResult::tool_error("Requires context")
1340    }
1341
1342    async fn execute_with_context(
1343        &self,
1344        arguments: Value,
1345        context: &ToolContext,
1346    ) -> ToolExecutionResult {
1347        let store = match require_file_store(context) {
1348            Ok(store) => store,
1349            Err(e) => return e,
1350        };
1351        simulate_latency(OpKind::Create).await;
1352
1353        let bucket_name = match arguments.get("bucket_name").and_then(|v| v.as_str()) {
1354            Some(n) => n,
1355            None => {
1356                return ToolExecutionResult::tool_error("Missing required parameter: bucket_name");
1357            }
1358        };
1359        let region = arguments
1360            .get("region")
1361            .and_then(|v| v.as_str())
1362            .unwrap_or("us-east-1");
1363        let versioning = arguments
1364            .get("versioning_enabled")
1365            .and_then(|v| v.as_bool())
1366            .unwrap_or(false);
1367        let encryption = arguments
1368            .get("encryption_enabled")
1369            .and_then(|v| v.as_bool())
1370            .unwrap_or(true);
1371
1372        let mut buckets = read_or_seed(store, context.session_id, S3_PATH, seed_s3).await;
1373
1374        let bucket = S3Bucket {
1375            name: bucket_name.to_string(),
1376            region: region.to_string(),
1377            creation_date: chrono::Utc::now().to_rfc3339(),
1378            versioning_enabled: versioning,
1379            encryption_enabled: encryption,
1380        };
1381        buckets.push(bucket);
1382
1383        match persist(store, context.session_id, S3_PATH, &buckets).await {
1384            Ok(_) => ToolExecutionResult::success(json!({
1385                "bucket_name": bucket_name,
1386                "status": "created",
1387                "region": region
1388            })),
1389            Err(e) => ToolExecutionResult::tool_error(format!("Failed to persist: {e}")),
1390        }
1391    }
1392
1393    fn requires_context(&self) -> bool {
1394        true
1395    }
1396}
1397
1398// ============================================================================
1399// Tool: aws_list_iam_users
1400// ============================================================================
1401
1402pub struct AwsListIamUsersTool;
1403
1404#[async_trait]
1405impl Tool for AwsListIamUsersTool {
1406    fn name(&self) -> &str {
1407        "aws_list_iam_users"
1408    }
1409
1410    fn display_name(&self) -> Option<&str> {
1411        Some("List IAM Users")
1412    }
1413
1414    fn description(&self) -> &str {
1415        "List all IAM users in the account."
1416    }
1417
1418    fn parameters_schema(&self) -> Value {
1419        json!({"type": "object", "properties": {}, "additionalProperties": false})
1420    }
1421
1422    fn hints(&self) -> ToolHints {
1423        ToolHints::default()
1424            .with_readonly(true)
1425            .with_idempotent(true)
1426    }
1427
1428    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
1429        ToolExecutionResult::tool_error("Requires context")
1430    }
1431
1432    async fn execute_with_context(
1433        &self,
1434        _arguments: Value,
1435        context: &ToolContext,
1436    ) -> ToolExecutionResult {
1437        let store = match require_file_store(context) {
1438            Ok(store) => store,
1439            Err(e) => return e,
1440        };
1441        simulate_latency(OpKind::List).await;
1442
1443        let users = read_or_seed(store, context.session_id, IAM_PATH, seed_iam).await;
1444
1445        ToolExecutionResult::success(json!({"users": users, "total_count": users.len()}))
1446    }
1447
1448    fn requires_context(&self) -> bool {
1449        true
1450    }
1451}
1452
1453// ============================================================================
1454// Tool: aws_create_iam_user
1455// ============================================================================
1456
1457pub struct AwsCreateIamUserTool;
1458
1459#[async_trait]
1460impl Tool for AwsCreateIamUserTool {
1461    fn name(&self) -> &str {
1462        "aws_create_iam_user"
1463    }
1464
1465    fn display_name(&self) -> Option<&str> {
1466        Some("Create IAM User")
1467    }
1468
1469    fn description(&self) -> &str {
1470        "Create a new IAM user."
1471    }
1472
1473    fn parameters_schema(&self) -> Value {
1474        json!({
1475            "type": "object",
1476            "properties": {
1477                "username": {"type": "string"},
1478                "permissions": {
1479                    "type": "array",
1480                    "items": {"type": "string"},
1481                    "description": "List of permission policies"
1482                }
1483            },
1484            "required": ["username"],
1485            "additionalProperties": false
1486        })
1487    }
1488
1489    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
1490        ToolExecutionResult::tool_error("Requires context")
1491    }
1492
1493    async fn execute_with_context(
1494        &self,
1495        arguments: Value,
1496        context: &ToolContext,
1497    ) -> ToolExecutionResult {
1498        let store = match require_file_store(context) {
1499            Ok(store) => store,
1500            Err(e) => return e,
1501        };
1502        simulate_latency(OpKind::Create).await;
1503
1504        let username = match arguments.get("username").and_then(|v| v.as_str()) {
1505            Some(u) => u,
1506            None => {
1507                return ToolExecutionResult::tool_error("Missing required parameter: username");
1508            }
1509        };
1510        let permissions: Vec<String> = arguments
1511            .get("permissions")
1512            .and_then(|v| v.as_array())
1513            .map(|arr| {
1514                arr.iter()
1515                    .filter_map(|v| v.as_str().map(String::from))
1516                    .collect()
1517            })
1518            .unwrap_or_default();
1519
1520        let mut users = read_or_seed(store, context.session_id, IAM_PATH, seed_iam).await;
1521
1522        let user_id = format!(
1523            "AIDAI{:016x}",
1524            chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0) as u64
1525        );
1526        let user = IamUser {
1527            username: username.to_string(),
1528            user_id: user_id.clone(),
1529            arn: format!("arn:aws:iam::123456789012:user/{}", username),
1530            created_at: chrono::Utc::now().to_rfc3339(),
1531            permissions,
1532        };
1533        users.push(user);
1534
1535        match persist(store, context.session_id, IAM_PATH, &users).await {
1536            Ok(_) => ToolExecutionResult::success(json!({
1537                "username": username,
1538                "user_id": user_id,
1539                "status": "created"
1540            })),
1541            Err(e) => ToolExecutionResult::tool_error(format!("Failed to persist: {e}")),
1542        }
1543    }
1544
1545    fn requires_context(&self) -> bool {
1546        true
1547    }
1548}
1549
1550// ============================================================================
1551// Tool: aws_list_security_groups
1552// ============================================================================
1553
1554pub struct AwsListSecurityGroupsTool;
1555
1556#[async_trait]
1557impl Tool for AwsListSecurityGroupsTool {
1558    fn name(&self) -> &str {
1559        "aws_list_security_groups"
1560    }
1561
1562    fn display_name(&self) -> Option<&str> {
1563        Some("List Security Groups")
1564    }
1565
1566    fn description(&self) -> &str {
1567        "List all security groups with their rules."
1568    }
1569
1570    fn parameters_schema(&self) -> Value {
1571        json!({"type": "object", "properties": {}, "additionalProperties": false})
1572    }
1573
1574    fn hints(&self) -> ToolHints {
1575        ToolHints::default()
1576            .with_readonly(true)
1577            .with_idempotent(true)
1578    }
1579
1580    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
1581        ToolExecutionResult::tool_error("Requires context")
1582    }
1583
1584    async fn execute_with_context(
1585        &self,
1586        _arguments: Value,
1587        context: &ToolContext,
1588    ) -> ToolExecutionResult {
1589        let store = match require_file_store(context) {
1590            Ok(store) => store,
1591            Err(e) => return e,
1592        };
1593        simulate_latency(OpKind::List).await;
1594
1595        let groups = read_or_seed(store, context.session_id, SG_PATH, seed_security_groups).await;
1596
1597        ToolExecutionResult::success(
1598            json!({"security_groups": groups, "total_count": groups.len()}),
1599        )
1600    }
1601
1602    fn requires_context(&self) -> bool {
1603        true
1604    }
1605}
1606
1607// ============================================================================
1608// Tool: aws_get_cloudwatch_metrics
1609// ============================================================================
1610
1611pub struct AwsGetCloudWatchMetricsTool;
1612
1613#[async_trait]
1614impl Tool for AwsGetCloudWatchMetricsTool {
1615    fn name(&self) -> &str {
1616        "aws_get_cloudwatch_metrics"
1617    }
1618
1619    fn display_name(&self) -> Option<&str> {
1620        Some("Get CloudWatch Metrics")
1621    }
1622
1623    fn description(&self) -> &str {
1624        "Get CloudWatch metrics for a resource (CPU, memory, disk, network)."
1625    }
1626
1627    fn parameters_schema(&self) -> Value {
1628        json!({
1629            "type": "object",
1630            "properties": {
1631                "resource_id": {"type": "string"},
1632                "metric_name": {
1633                    "type": "string",
1634                    "enum": ["CPUUtilization", "MemoryUtilization", "DiskReadOps", "NetworkIn"],
1635                    "description": "Metric to retrieve"
1636                },
1637                "period_minutes": {
1638                    "type": "integer",
1639                    "description": "Time period in minutes (default: 60)"
1640                }
1641            },
1642            "required": ["resource_id", "metric_name"],
1643            "additionalProperties": false
1644        })
1645    }
1646
1647    fn hints(&self) -> ToolHints {
1648        ToolHints::default()
1649            .with_readonly(true)
1650            .with_idempotent(true)
1651    }
1652
1653    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
1654        ToolExecutionResult::tool_error("Requires context")
1655    }
1656
1657    async fn execute_with_context(
1658        &self,
1659        arguments: Value,
1660        context: &ToolContext,
1661    ) -> ToolExecutionResult {
1662        let store = match require_file_store(context) {
1663            Ok(store) => store,
1664            Err(e) => return e,
1665        };
1666        simulate_latency(OpKind::Query).await;
1667
1668        let resource_id = match arguments.get("resource_id").and_then(|v| v.as_str()) {
1669            Some(id) => id,
1670            None => {
1671                return ToolExecutionResult::tool_error("Missing required parameter: resource_id");
1672            }
1673        };
1674        let metric_name = match arguments.get("metric_name").and_then(|v| v.as_str()) {
1675            Some(m) => m,
1676            None => {
1677                return ToolExecutionResult::tool_error("Missing required parameter: metric_name");
1678            }
1679        };
1680
1681        // Verify the resource actually exists (check EC2 instances)
1682        let instances = read_or_seed(store, context.session_id, EC2_PATH, seed_ec2).await;
1683        let instance_exists = instances.iter().any(|i| i.instance_id == resource_id);
1684
1685        if !instance_exists {
1686            // Also check RDS
1687            let databases = read_or_seed(store, context.session_id, RDS_PATH, seed_rds).await;
1688            let rds_exists = databases.iter().any(|d| d.db_instance_id == resource_id);
1689            if !rds_exists {
1690                return ToolExecutionResult::tool_error(format!(
1691                    "Resource not found: {}",
1692                    resource_id
1693                ));
1694            }
1695        }
1696
1697        // Generate metric datapoints — use per-resource profiles for seed instances
1698        // so auditor sees realistic data (idle instances have low CPU, etc.),
1699        // with deterministic variation from resource_id hash.
1700        let hash: u32 = resource_id
1701            .bytes()
1702            .fold(0u32, |acc, b| acc.wrapping_mul(31).wrapping_add(b as u32));
1703        let base = metric_profile_base(resource_id, metric_name).unwrap_or(match metric_name {
1704            "CPUUtilization" => 30.0,
1705            "MemoryUtilization" => 55.0,
1706            "DiskReadOps" => 150.0,
1707            "NetworkIn" => 1024.0 * 50.0,
1708            _ => 30.0,
1709        });
1710        let datapoints: Vec<Value> = (0..12)
1711            .map(|i| {
1712                let variation = ((hash.wrapping_add(i * 7)) % 200) as f64 / 10.0 - 10.0;
1713                let avg = (base + variation * (base / 30.0)).max(0.0);
1714                json!({
1715                    "timestamp": chrono::Utc::now() - chrono::Duration::minutes(i as i64 * 5),
1716                    "average": (avg * 100.0).round() / 100.0,
1717                    "minimum": ((avg * 0.7).max(0.0) * 100.0).round() / 100.0,
1718                    "maximum": (avg * 1.3 * 100.0).round() / 100.0
1719                })
1720            })
1721            .collect();
1722
1723        ToolExecutionResult::success(json!({
1724            "resource_id": resource_id,
1725            "metric_name": metric_name,
1726            "datapoints": datapoints,
1727            "period": "5 minutes"
1728        }))
1729    }
1730
1731    fn requires_context(&self) -> bool {
1732        true
1733    }
1734}