跳到正文
DEV Community· David Ernst·· 3 小時前AI 評分71

AI 智能體如何以唯讀權限盤點 AWS 帳戶並分析成本

The Easy Button for AWS Account Details and Cost Savings

AI 導讀

作者展示如何透過 IAM 角色、Kiro 審批與規則,以及 CloudTrail,讓 AI 智能體以唯讀方式盤點 AWS 帳戶並分析成本。實測帳戶的 QuickSight 和 Neptune 未出現在資源清單,卻佔帳單成本的 98%;CloudTrail 記錄 445 條事件,沒有寫入成功,兩次寫入測試均遭拒絕。

正文

How I gave an AI agent enough access to map an AWS account and build a savings report, and no more.

The first question I get when I show an AI agent doing discovery on a live AWS account isn’t about speed. It’s about access.

“You gave it what?”

It’s the right question. An agent that maps an account in minutes is useful. An agent that can change things in someone else’s account is a liability. The speed is what gets attention. The read-only part is what makes it safe to do at all.

This post isn’t a getting-started tutorial. It walks through the role, the two MCP servers, the places where “read-only” quietly wasn’t, and what the agent found when I pointed it at a real bill. The key code is inline, and the full Terraform, Kiro config, prompts, and CloudTrail check script are in the companion repo: https://github.com/dmernst/aws-readonly-discovery-agent.

Do this a day ahead: opt in to Cost Optimization Hub and Compute Optimizer. Both are free, and each can take up to 24 hours to produce its first recommendations.

The architecture

  • AWS MCP Server, the managed remote server that became generally available in May 2026. It gives the agent the whole AWS API through a sandboxed aws___run_script tool.
  • AWS Billing and Cost Management MCP server, the open-source server from AWS Labs. It wraps Cost Explorer, Cost Optimization Hub, Compute Optimizer, Savings Plans, Budgets, and the Price List API.

Figure 1. Two MCP servers, one read-only role, and CloudTrail as the record. The customer’s organization adds its own guardrail on top.

One is managed and remote; the other runs on my laptop. That difference matters, and I’ll come back to it.

Read-only

When people say “give it read-only access,” they usually mean: attach ReadOnlyAccess and move on. That's the wrong policy for this job. ReadOnlyAccess includes reading data, not just metadata: s3:GetObject, DynamoDB scans, CloudWatch Logs events. A discovery agent needs to know a bucket exists and how big it is. It doesn't need to open the objects.

Read-only should mean the agent can see the shape of the environment, not its contents.

So the guardrail is four layers, and each one catches something the others miss:

  1. IAM role: metadata reads plus a scoped cost policy, with an explicit deny on data-plane reads.
  2. Kiro approvals: with Autopilot off, I approve every tool call and shell command before it runs.
  3. Kiro steering: written rules about what the agent does, what it cites, and when it stops.
  4. CloudTrail: proof after the fact.

Layer 1: The IAM role

The role starts from ViewOnlyAccess, the AWS managed job-function policy for listing and describing resources without reading their contents. Sessions are capped at one hour; a discovery run is an afternoon, not a week.

resource "aws_iam_role" "discovery" {
  name                 = var.role_name
  description          = "Read-only discovery and cost analysis for AI agents via MCP"
  assume_role_policy   = data.aws_iam_policy_document.trust.json
  max_session_duration = var.max_session_hours * 3600
}
resource "aws_iam_role_policy_attachment" "view_only" {
  role       = aws_iam_role.discovery.name
  policy_arn = "arn:aws:iam::aws:policy/job-function/ViewOnlyAccess"
}

The trust policy names exactly who can assume it, with an optional external ID for when a partner assumes the role into a customer account:

data "aws_iam_policy_document" "trust" {
  statement {
    sid     = "AllowNamedPrincipals"
    effect  = "Allow"
    actions = ["sts:AssumeRole", "sts:SetSourceIdentity"]
    principals {
      type        = "AWS"
      identifiers = var.trusted_principal_arns
    }
    dynamic "condition" {
      for_each = var.external_id == "" ? [] : [var.external_id]
      content {
        test     = "StringEquals"
        variable = "sts:ExternalId"
        values   = [condition.value]
      }
    }
  }
}

The cost permissions: where “read-only” quietly isn’t

The Billing and Cost Management server’s README lists the permissions it needs. Most are what you’d expect: ce:Get*, compute-optimizer:Get*, pricing:GetProducts. Read the whole list and you'll also find s3:PutObject, s3:PutStorageLensConfiguration, athena:CreateWorkGroup, athena:CreateTable, and several ce:Start* calls. None of it is malicious; Storage Lens needs somewhere to write Athena results. But copying the README's list gives your "read-only" agent write access to S3 and Athena.

So I scoped it to Get, List, and Describe, and gave up Storage Lens analysis in exchange:

statement {
  sid    = "CostExplorerRead"
  effect = "Allow"
  actions = [
    "ce:GetCostAndUsage",
    "ce:GetCostAndUsageWithResources",
    "ce:GetCostAndUsageComparisons",
    "ce:GetCostComparisonDrivers",
    "ce:GetCostForecast",
    "ce:GetUsageForecast",
    "ce:GetDimensionValues",
    "ce:GetTags",
    "ce:GetCostCategories",
    "ce:GetAnomalies",
    "ce:GetReservationCoverage",
    "ce:GetReservationUtilization",
    "ce:GetReservationPurchaseRecommendation",
    "ce:GetSavingsPlansCoverage",
    "ce:GetSavingsPlansUtilization",
    "ce:GetSavingsPlansUtilizationDetails",
    "ce:GetSavingsPlansPurchaseRecommendation",
    "ce:GetSavingsPlanPurchaseRecommendationDetails",
    "ce:ListCostAllocationTags",
    "ce:ListCostCategoryDefinitions",
    "ce:DescribeCostCategoryDefinition",
  ]
  resources = ["*"]
}

The rest of the cost policy follows the same pattern for Cost Optimization Hub, Compute Optimizer (including GetIdleRecommendations), Savings Plans, Budgets, Pricing, Free Tier, and CloudWatch metrics. It cost nothing in practice: CloudTrail shows 31 billing calls from the agent, and none was denied.

The explicit deny

The last statement denies data-plane reads:

statement {
  sid    = "DenyDataPlaneReads"
  effect = "Deny"
  actions = [
    "s3:GetObject",
    "s3:GetObjectVersion",
    "secretsmanager:GetSecretValue",
    "ssm:GetParameter",
    "ssm:GetParameters",
    "ssm:GetParametersByPath",
    "kms:Decrypt",
    "dynamodb:GetItem",
    "dynamodb:BatchGetItem",
    "dynamodb:Query",
    "dynamodb:Scan",
    "logs:GetLogEvents",
    "logs:FilterLogEvents",
    "logs:StartQuery",
    "lambda:GetFunction",
    "ec2:GetPasswordData",
    "ec2:GetConsoleOutput",
    "rds-data:*",
    "sqs:ReceiveMessage",
    "kinesis:GetRecords",
    "athena:GetQueryResults",
    "glue:GetConnection",
    "glue:GetConnections",
  ]
  resources = ["*"]
}

ViewOnlyAccess shouldn't grant most of these today. The deny is insurance against tomorrow: a managed policy update, or a teammate attaching one more policy "just for this run." An explicit deny wins over any allow. (lambda:GetFunction is here because its response includes a link to download the function's code.) Writes need no deny; nothing in the role allows one.

The condition keys, and the gap they leave

The AWS MCP Server adds two condition keys to every request it forwards, aws:ViaAWSMCPService and aws:CalledViaAWSMCP, so IAM policies and SCPs can treat agent traffic differently from a human's.

The gap: only AWS managed MCP servers add those keys. The billing server runs locally and calls Cost Explorer with my profile’s credentials. To IAM, those calls look exactly like me running the AWS CLI.

Condition keys are a good second layer. The role has to be the first.

Deploy it

Two things before the first command. Get off root: my first aws login on this laptop was as the root user, which can't assume a role and can't be limited by policy, so I created an IAM user with an admin profile for the deploy. Say no to the CLI's offer: recent versions of aws configure offer to "Configure AWS skills and the AWS MCP server for your AI coding agent(s)." That installs at the user level with your admin profile. The only MCP config should be the one in the workspace.

cd terraform
cp terraform.tfvars.example terraform.tfvars   # set trusted_principal_arns
terraform init
terraform plan -out tfplan
terraform apply tfplan
terraform output -raw aws_config_profile >> ~/.aws/config   # then set source_profile
aws sts get-caller-identity --profile mcp-discovery

The plan should show one role, one policy attachment, and two inline policies; anything else, stop and read it. If plan fails with No valid credential sources found, set AWS_PROFILE to your admin profile. And if you write your own boundary tests, skip ec2 create-tags --dry-run on a made-up ID: EC2 checks the resource before the permission, so it returns InvalidVpcID.NotFound and proves nothing. create-vpc --dry-run and a real s3api create-bucket give clean denials.

Layer 2: Wiring both servers into Kiro

Kiro reads MCP servers from .kiro/settings/mcp.json in the workspace, which takes precedence over the user-level file. Two gotchas: open the folder that contains .kiro/, not its parent, and trust the folder, then quit Kiro completely and reopen it. Until then the log says workspace scope skipped: workspace is not trusted and nothing starts.

{
  "mcpServers": {
    "aws-mcp": {
      "command": "uvx",
      "timeout": 100000,
      "transport": "stdio",
      "args": [
        "mcp-proxy-for-aws-cli@1.7.0",
        "https://aws-mcp.us-east-1.api.aws/mcp",
        "--metadata", "AWS_REGION=us-east-1",
        "--profile", "mcp-discovery"
      ],
      "disabled": false,
      "autoApprove": []
    },
    "billing-cost-management": {
      "command": "uvx",
      "args": ["awslabs.billing-cost-management-mcp-server@0.0.39"],
      "env": {
        "FASTMCP_LOG_LEVEL": "ERROR",
        "AWS_PROFILE": "mcp-discovery",
        "AWS_REGION": "us-east-1"
      },
      "disabled": false,
      "autoApprove": []
    }
  }
}

SigV4 through the proxy lets both servers share one profile, so CloudTrail shows one role and one session name. Versions are pinned, because in a customer account I want to know exactly what ran. autoApprove is empty and Autopilot is off, so Kiro asks before every tool call and shell command.

Figure 2. Autopilot off (bottom right): every tool call and shell command waits for my approval.

What I asked: “Which AWS account and role are you connected as? List the tools you have from each MCP server, and tell me which of them can write.”

My first config had one more line: --read-only. AWS's setup guide says it hides write-capable tools, which sounded like exactly what I wanted. The smoke test showed what it actually does: aws-mcp came up with six tools, and aws___run_script, the one tool that calls AWS APIs, was gone. The agent could read about AWS all day; it couldn't list a single resource in my account.

Figure 3. With *--read-only: six tools, and none of them can see the account.*

The flag hides tools. The role decides what any tool can actually do.

So I removed it. I’d already tested the role from the CLI: ec2 create-vpc returned UnauthorizedOperation, s3api create-bucket returned AccessDenied, and ssm get-parameter came back with an explicit deny in an identity-based policy. The agent now sees a tool that could write, and AWS rejects the call if it tries.

Figure 4. Without the flag: eight tools, including *aws___run_script.*

The billing server listed about 50 tools, and Kiro flagged two as write-capable on its own: the Savings Plans start_* jobs. The role doesn't allow ce:Start*, so both would be denied.

Figure 5. Kiro’s audit of the billing server: about 50 tools, two that can write.

The smoke test also showed a path I hadn’t planned for. Before touching either MCP server, the agent ran the aws CLI in Kiro's terminal. It failed only because I don't keep a default profile, but an admin profile one --profile flag away is a second road into the account that skips the role entirely. I closed it with a steering rule and Autopilot off.

Layer 3: Steering the agent

Steering files are Markdown rules the agent includes in every conversation. IAM decides what the agent can do. Steering decides what it should do, and how it reports back.

---
inclusion: always
---
# Discovery and savings rules
- Read only. Never attempt a create, update, delete, start, stop, tag, or put call, even if a tool offers one. If a finding needs a change, write it as a recommendation for a human.
- Evidence for every finding: the tool and API, the resource ARN or ID, the time window, and the numbers.
- Separate what you observed from what you inferred.
- No secrets or data. If a call is denied, record the denial and move on; do not look for a workaround.
- Cost Explorer calls cost money (about $0.01 per request). Use monthly granularity unless daily is needed, and don't repeat the same query.
- AWS access only through the MCP servers. Never run the aws CLI or SDK scripts in the terminal, and never pass --profile or read ~/.aws.

The rule I care most about is the third one. Compute Optimizer gives you a measured recommendation; an agent reasoning from metadata gives you an inference. Both are useful, and they should never share a column in a report. It’s the same habit I learned in Bar Raiser debriefs: what did we observe, and what are we assuming?

The account

I didn’t want to demo this on an empty account, so I used a sandbox that runs a small serverless app and belongs to an AWS Organization. I don’t own it, which made it the right test: this is how a partner gets into a customer account. The account’s admin deployed the same Terraform, trusting only my user and requiring an external ID, and I added one profile:

[profile mcp-discovery]
role_arn          = arn:aws:iam::<their-account-id>:role/mcp-discovery-readonly
source_profile    = my-signin-profile
external_id       = <agreed external id>
role_session_name = kiro-discovery

That took about ten minutes, with a separate copy of the Terraform folder and its own state, so a terraform destroy could never hit the wrong account. Before handing it to the agent, I checked the boundary from the CLI. Reads worked and writes were denied, and the most reassuring check: calling AssumeRole without the external ID was denied, even from the user the role trusts.

Discovery: mapping the account

My first sweep hit a guardrail I hadn’t put there. Thirteen of seventeen Regions came back with this:

... is not authorized to perform: ec2:DescribeVolumes with an explicit deny in a service control policy

The organization’s SCP limits the account to four US Regions, and my role can’t override it, which is exactly how it should work. The error text tells you which layer said no: “identity-based policy” means my role, “service control policy” means the customer’s organization.

Then the agent made its first real mistake. It wrote a regex over its own tool output to sort allowed Regions from denied ones, got the lists backwards, and kept going. I stopped it and started a fresh chat with the four Regions named up front.

What I asked: “A service control policy limits this account to us-east-1, us-east-2, us-west-1, and us-west-2, so only scan those four Regions and don’t treat denials elsewhere as findings. Do a read-only discovery: inventory compute, storage, databases, networking, and anything that bills hourly. Write **reports/inventory.md with counts by service and Region and a Mermaid diagram of how the main pieces connect."

Figure 6. Second try: four Regions named up front, services swept in parallel batches. 8 minutes, 8 tool calls, 2.68 Kiro credits.

It found a tidy serverless app, almost entirely in us-east-1:

  • Compute: 9 Lambda functions; no EC2, containers, or Auto Scaling groups
  • Data: 2 DynamoDB tables (on-demand) and 4 S3 buckets; no RDS, ElastiCache, or OpenSearch
  • Front door: CloudFront in front of a static site, and one API Gateway REST API
  • Glue and operations: 4 EventBridge schedules, 2 SNS topics, 12 CloudWatch alarms, and 4 Secrets Manager secrets
  • Networking: default VPCs only

Figure 7. What discovery mapped, and the two services only the bill revealed.

Widening the role for metadata, not data

The inventory lists all four secrets by name (an OAuth client secret and three Slack tokens and webhooks) along with when each was last accessed. It can’t read a single value. That’s the line I’d point to in a security review.

It took one change. ViewOnlyAccess doesn't cover a few metadata calls, and on an early test run the agent reported them as denied instead of guessing. I granted exactly those calls:

statement {
  sid    = "DiscoveryMetadataRead"
  effect = "Allow"
  actions = [
    "s3:GetBucketLocation",
    "s3:GetBucketVersioning",
    "secretsmanager:ListSecrets",
    "cloudwatch:DescribeAlarms",
    "amplify:ListApps",
  ]
  resources = ["*"]
}

Every one returns names or settings, not contents; GetSecretValue is still in the explicit deny. Widen the role for metadata when the agent shows you a gap. Never widen it for data.

This run still reported three gaps: GuardDuty, Bedrock, and QuickSight. For the first two, the agent found the account’s service-linked roles and worked out both services were in use without asking for more access. QuickSight turned out to matter a lot, and the bill is how I found out.

The savings pass

The inventory says what exists. The bill says what it costs. The savings come from comparing them.

Step 1: Where the money goes

What I asked: “Using Cost Explorer, show the last three full months of cost by service and by Region, and the month-over-month change. What are the top five cost drivers, and is anything growing faster than the rest?”

The account went from under four dollars a month to $335 in two months, and two services account for 98% of it. The agent cited the Cost Explorer request IDs for both queries, which is the evidence the steering asks for. It also rewrote a report file I hadn’t asked for; the review bar caught it with a Revert button. Steering doesn’t stop the agent from writing files in your workspace. The review gate does.

Step 2: What AWS already computed

What I asked: “Pull the current recommendations from Cost Optimization Hub and Compute Optimizer, including idle resources. Also check Savings Plans and Reserved Instance coverage and utilization. If a service isn’t enrolled, tell me instead of guessing.”

Both services are opt-in, and neither has anything to show the moment you turn it on. Cost Optimization Hub also pulls its rightsizing and idle findings from Compute Optimizer, so you need both. Opting in is a write, so the read-only role can’t do it; I ran it as the admin:

aws cost-optimization-hub update-enrollment-status --status Active --region us-east-1
aws compute-optimizer update-enrollment-status --status Active --region us-east-1

[Recommendations actually took about X hours to appear.] Everything else in this post works immediately.

[Results: the measured recommendations, with estimated monthly savings. Did the engines flag anything the cross-check in Step 3 missed, or the other way around?]

Step 3: The bill knew what the inventory couldn’t

What I asked: “Using the inventory and a fresh Cost Explorer pull of the last three full months, cross-check the inventory against the bill. Start with the two biggest costs: for QuickSight, how many users, which edition, and how much SPICE capacity is in use versus paid for; for Neptune, the instance size and its CPU and connection metrics since it was created. Then check the usual misses in the four allowed Regions: unattached EBS volumes, old snapshots, idle load balancers, unused Elastic IPs, NAT gateways, and log groups with no retention. Back every finding with metadata or CloudWatch metrics.”

The inventory had just reported a tidy serverless app. No QuickSight. No Neptune. Yet those two services were 98% of the bill.

Figure 8. “The bill reveals services the inventory missed entirely.”

QuickSight was invisible because of my role. Every QuickSight API call was denied, so the agent read the bill instead, grouping Cost Explorer by usage type:

  • USE1-Author-Pro-Enterprise-Month: one Enterprise author seat, $40
  • USE1-Amazon-Q-QS-Fee: the Amazon Q in QuickSight add-on, $250
  • No SPICE usage types at all: no data loaded

Neptune was invisible because it was gone. DescribeDBClusters returned nothing in all four Regions. The bill showed a db.r8g.xlarge that ran about 52 hours in September and about 230 in October, roughly $210 in total, deleted both times without a snapshot. A default parameter group was the only trace left. An inventory only sees what exists today. The bill remembers what existed last month.

Figure 9. Everything here came from usage types and hours, not from the QuickSight or Neptune APIs.

The usual misses came back clean: no unattached volumes, snapshots, load balancers, Elastic IPs, or NAT gateways. The only hygiene finding was 11 log groups set to never expire, about 2 MB, so it costs nothing here.

Figure 10. The cross-check: 9 minutes, 3.15 credits, and every finding handed back to a human.

What it got wrong

Three claims didn’t survive my review, and they’re worth more to you than the ones that did.

Its first answer was confidently wrong. Before it broke the bill down by usage type, it explained the $290 like this:

Figure 11. Plausible, precise, and wrong.

The arithmetic works; the explanation doesn’t. It was one $40 seat and a $250 add-on. When an agent explains a number, ask it what the number is made of.

It invented a reason. Finding no CloudWatch metrics for Neptune, it suggested the cluster had been stopped, and that “stopped clusters still accrue instance charges.” That’s backwards: AWS’s Neptune documentation says “you aren’t charged for any DB instance hours” while a cluster is stopped. It labeled this as a guess, but it’s exactly the kind of claim that sounds right in a report.

It priced savings on something that no longer exists: “$178 to $535 a month” for a cluster already deleted. The honest number is $0 saved and about $210 already spent. The real recommendation is a process one: experiments need an owner and a budget alert.

It also suggested granting quicksight:Describe* to fill the QuickSight gap. I didn't: Describe* includes calls that return dashboard and dataset definitions, which is content. Widen for metadata, never for data.

The report

On a $335 bill, that’s about 80%, almost all from one line item nobody would have found by looking at the resources. None of it was applied by the agent. The agent did the digging; the judgment stayed with me.

Layer 4: Proving it stayed read-only

The last thing I asked the agent was to break its own rules.

What I asked: “Create an empty S3 bucket named **mcp-write-test- in us-east-1."

I picked a write whose worst case is an empty bucket, not a deleted resource. A test of your guardrails shouldn’t be able to hurt you if they fail. It refused, and when I told it to try anyway, it refused again:

Figure 12. “The constraint is in the workspace rules I’m operating under, not just in IAM policy enforcement.”

That proves the steering works, not the role, which is why I’d already tested the role myself from the CLI. Test each layer on its own.

Then the check that matters more than anything the agent says about itself: CloudTrail. I pulled every event from the kiro-discovery session across all four Regions. 445 events. Zero writes succeeded. The only write attempts were my two CLI tests, both denied. One catch if you run this yourself: the AWS MCP Server logs each of its own tool calls as a non-read-only event, so filter out aws-mcp.amazonaws.com before you count writes.

The denied reads told a story of their own. Twenty-four were kms:Decrypt calls the agent never made. Their source was lambda.amazonaws.com: when the agent listed the Lambda functions, Lambda tried to decrypt their environment variables on its behalf, and the explicit deny stopped it. Environment variables are where a lot of teams still keep secrets. None of them reached the agent, and the deny I'd added as insurance is the only reason why.

The records also show the condition-key gap from earlier. Calls through the AWS MCP Server carry aws-mcp.amazonaws.com as the source and user agent. The billing server's calls show my laptop's IP and Boto3, exactly like a script I'd run myself.

The agent’s description of what it did is a claim. CloudTrail is the evidence.

What it cost

  • IAM role and AWS MCP Server: no charge.
  • Cost Explorer API: 15 requests at about $0.01 each, so about $0.15.
  • Kiro: about 8 credits for the runs in this post, and a little over 17 of the free plan’s 50 counting practice runs and two false starts.
  • My time: about 2 hours 15 minutes, start to finish, on a laptop without the AWS CLI or Terraform installed.

By hand, for an account this size, I’d estimate the better part of a day. The agent did it in about 25 minutes. But speed isn’t the bigger difference. A person clicking through the console would likely never see these findings. The $250 add-on doesn’t appear on any QuickSight screen; it’s one usage type, visible only if you group the bill that way. The Neptune cluster wasn’t in the console at all. The agent didn’t get tired of looking, and it checked every place it was told to, in every Region it was allowed to.

The time I didn’t save was the review, and I wouldn’t want to. Half an hour of checking found three wrong claims. That part of the job stays with a person.

Before you point this at production

  • Start from ViewOnlyAccess, not ReadOnlyAccess. The agent needs the shape of the environment, not its contents.
  • Read the MCP server’s permission list line by line. “Read-only server” and “read-only permissions” are not the same thing.
  • Remember that condition keys only cover managed servers. Local servers look like you.
  • Keep sessions short and the session name fixed, so CloudTrail tells a clean story.
  • Point it at the bill, not just the resources. The biggest findings here never showed up in a console walkthrough.
  • Make the agent separate measured from inferred, and keep a human on every change.
  • Verify in CloudTrail before you tell a customer it was read-only.

That first question, “You gave it what?”, is still the right one to ask. The difference now is that I can answer it with a role, four layers, and a CloudTrail summary that says zero writes.

Speed is what gets people interested. Read-only access is what makes it safe to say yes. Together, you get faster insights without the manual effort.

來源:DEV Community · dev.to