Trust

What Spendsaw can reach in your AWS accounts, what the model sees, what leaves your account, and what has not been proven yet. Everything on this page is taken from the code and infrastructure definitions, and each section names the file it comes from.

Role and policy names still use the internal name, CostAgent. Spendsaw is a 45Squared venture.

Status

Spendsaw is pre-launch. Read the rest of this page with that in mind: it describes what the code and templates do, and this section says how far each part has been proven.

  • Built, tested on mocksScanners, pricing, remediation templates and pre-checks, the approval flow, the API, Slack delivery, Terraform pull requests, the MCP server and the hosted workers have automated tests that run against mocked AWS (moto) and fake Slack and GitHub clients.
  • Checked offlineThe hosted Terraform is formatted, validated and plan-tested against a mocked AWS provider. The read-only and remediator CloudFormation templates pass cfn-lint.
  • Run liveOn 12 October 2026 the hosted service was deployed and scanned our own AWS organization through the read-only role: 4 accounts, 17 regions each, 68 scan targets, none failed. The read API returned 401 without a token and with a forged one, and findings and savings with a valid one. A second test tenant that claimed one of the first tenant's accounts was refused at sts:AssumeRole, read 0 findings and got 404 for the first tenant's findings. These were runs in our own accounts on one day, not an audit.
  • Not yet validated liveRemediation has never run against a real account. The remediator role is not deployed anywhere and remediation is switched off in the hosted service, so its policy on this page is proven only on mocks, and the mock library does not enforce IAM. A worker that sets the wrong tenant tag itself has not been tested.
  • Not yet validated liveThe prompt-injection test and the savings-accuracy target have not been run. The hosted workers do not yet price findings from the Cost and Usage Report, so hosted amounts are list prices, and rightsizing estimates do not discount commitments such as reservations.
  • Built, not hostedSlack approvals, spike alerts, Terraform pull requests, the model's explanations, verified savings, the monthly report and billing run only in the self-hosted application. The hosted service makes no model call today. The sections below describe that code; what runs today lists what the hosted service has.
  • No reportThere is no SOC 2 report and no audit is in progress. An internal readiness checklist has been started. There are no security certifications and no third-party penetration test.
  • DraftTerms of service, a privacy policy and a data processing addendum exist as drafts that no lawyer has reviewed. They are not in force.

Sources: docs/live-validation.md, infra/README.md (validation status), tests/, workers/scan.py, workers/api.py.

The roles

Spendsaw reaches your account only through IAM roles that you deploy from CloudFormation templates you can read first. There are no access keys to hand over. Reading and writing are separate roles.

RoleNeededWhat it is for
CostAgentReadOnlyAlwaysScanning. Metadata, metrics and cost totals only, with an explicit deny on reading your data.
CostAgentRemediatorOpt-inRunning approved low and medium risk actions. Deployed only in accounts where you want remediation. Never in the management account.
CostAgentStateReaderOpt-inReading Terraform state files from one bucket, so Terraform-managed resources can be told from click-ops. Only needed for Terraform pull requests.

The remediator is kept out of the management account in several independent places: the template does not create the role when the stack's account equals ManagementAccountId; the Terraform that deploys it refuses the management account in variable validation and a precondition; and the application refuses to assume or use the role there.

All three roles cap sessions at 3600 seconds. The application asks for 900 seconds when it assumes the remediator.

Sources: cloudformation/*.yaml, aws/account_manager.py, remediation/approvals.py, infra/terraform/modules/onboarding/main.tf.

ExternalId and the trust policy

Each role trusts one AWS account, the account that runs Spendsaw, and only when the caller presents your tenant's ExternalId. This is the trust statement on all three roles:

Effect: Allow
Principal:
  AWS: arn:aws:iam::<TrustedAccountId>:root
Action: sts:AssumeRole
Condition:
  StringEquals:
    sts:ExternalId: <your ExternalId>
  ArnLike:
    aws:PrincipalArn: arn:aws:iam::<TrustedAccountId>:role/<TrustedRoleName>
  • The ExternalId protects against the confused-deputy problem. Spendsaw holds roles in many customers' accounts; without an ExternalId, another customer who learned your role ARN could ask Spendsaw to scan your account as theirs. With it, the assume-role call only succeeds when Spendsaw presents the value issued to your tenant.
  • In the hosted design the ExternalId is 16 random bytes (32 hex characters) generated per tenant and stored in AWS Secrets Manager. The template requires at least 16 characters and marks the parameter NoEcho.
  • TrustedRoleName narrows the trust to one role in the trusted account. If you leave it empty, any principal in that account that holds sts:AssumeRole and your ExternalId can assume the role.
  • The application passes the tenant's ExternalId on every AssumeRole call.

Sources: cloudformation/cost-agent-readonly.yaml, infra/terraform/modules/tenant/main.tf, aws/account_manager.py.

Read-only role: exact permissions

CostAgentReadOnly attaches the AWS-managed policy ViewOnlyAccess and one inline policy. ViewOnlyAccess is maintained by AWS and grants list and describe calls across services; AWS can change its contents. The inline policy below is ours, shown in full.

Inline policy CostAgentScancloudformation/cost-agent-readonly.yaml
AllowCostAndPricing

Cost Explorer totals, public AWS price lists, and the recommendations AWS already produces for the account.

  • ce:Get*
  • ce:Describe*
  • ce:List*
  • pricing:*
  • compute-optimizer:Get*
  • cost-optimization-hub:List*
  • cost-optimization-hub:Get*

Resource: *

AllowMetrics

CloudWatch metric statistics, used as the evidence that a resource is idle. Metrics, not logs.

  • cloudwatch:GetMetric*
  • cloudwatch:ListMetrics

Resource: *

AllowBedrockReadOnly

Bedrock inventory and configuration: provisioned throughput, custom models, knowledge bases, agents and guardrails. These are wildcards, so they include configuration reads. Agent memory and knowledge-base retrieval are denied below.

  • bedrock:List*
  • bedrock:Get*

Resource: *

AllowCallerAttribution

Works out which Lambda functions and ECS services can call Bedrock. The scanner looks up CloudTrail management events whose source is bedrock.amazonaws.com and keeps the caller ARN, client type and event name. It reads role policies only to check whether they mention Bedrock; the policy documents are not stored.

  • cloudtrail:LookupEvents
  • iam:ListAttachedRolePolicies
  • iam:ListRolePolicies
  • iam:GetRolePolicy

Resource: *

AllowOrgAndTags

Lists the accounts in the organization, and reads resource tags so keep tags and Terraform ownership tags can be honoured.

  • organizations:ListAccounts
  • organizations:DescribeOrganization
  • tag:GetResources

Resource: *

AllowScannerMetadata

The describe and list calls the scanners make, named one by one so the role does not depend on what the AWS-managed policy happens to contain. secretsmanager:ListSecrets returns secret names and last-accessed dates, never values.

  • ec2:DescribeAddresses
  • ec2:DescribeImages
  • ec2:DescribeInstances
  • ec2:DescribeNatGateways
  • ec2:DescribeRegions
  • ec2:DescribeSnapshots
  • ec2:DescribeSubnets
  • ec2:DescribeVolumes
  • ec2:DescribeVpcEndpoints
  • ec2:DescribeVpcPeeringConnections
  • elasticloadbalancing:DescribeLoadBalancers
  • elasticloadbalancing:DescribeTags
  • elasticloadbalancing:DescribeTargetGroups
  • elasticloadbalancing:DescribeTargetHealth
  • rds:DescribeDBClusters
  • rds:DescribeDBInstances
  • rds:ListTagsForResource
  • s3:ListAllMyBuckets
  • s3:GetBucketLocation
  • s3:GetBucketTagging
  • s3:GetIntelligentTieringConfiguration
  • s3:GetLifecycleConfiguration
  • logs:DescribeLogGroups
  • logs:ListTagsForResource
  • lambda:ListFunctions
  • lambda:GetFunctionConfiguration
  • lambda:ListTags
  • dynamodb:ListTables
  • dynamodb:DescribeTable
  • dynamodb:ListTagsOfResource
  • elasticache:DescribeCacheClusters
  • elasticache:ListTagsForResource
  • es:ListDomainNames
  • es:DescribeDomain
  • es:ListTags
  • redshift:DescribeClusters
  • ecs:ListClusters
  • ecs:ListServices
  • ecs:DescribeServices
  • ecs:DescribeTaskDefinition
  • kinesis:ListStreams
  • kinesis:DescribeStreamSummary
  • glue:GetJobs
  • glue:GetJobRuns
  • sagemaker:ListNotebookInstances
  • sagemaker:ListEndpoints
  • cloudfront:ListDistributions
  • route53:ListHostedZones
  • secretsmanager:ListSecrets

Resource: *

AllowApiGatewayInventory

Lists REST and HTTP APIs. Limited to the two list paths.

  • apigateway:GET

Resource: arn:aws:apigateway:*::/restapis and arn:aws:apigateway:*::/apis

DenyDenyDataPlaneReads

An explicit deny on reading your data: S3 objects, table items, secret values, parameters, log events, function code, queue messages, stream records, container images, repository contents, and Bedrock agent memory and retrieval. An explicit deny overrides any allow, including anything ViewOnlyAccess grants now or later.

  • s3:GetObject*
  • dynamodb:GetItem
  • dynamodb:BatchGetItem
  • dynamodb:Query
  • dynamodb:Scan
  • dynamodb:PartiQLSelect
  • dynamodb:GetRecords
  • secretsmanager:GetSecretValue
  • secretsmanager:BatchGetSecretValue
  • ssm:GetParameter*
  • kms:Decrypt
  • logs:GetLogEvents
  • logs:FilterLogEvents
  • logs:GetLogRecord
  • logs:GetQueryResults
  • logs:StartQuery
  • logs:StartLiveTail
  • logs:Unmask
  • lambda:GetFunction
  • sqs:ReceiveMessage
  • kinesis:GetRecords
  • athena:GetQueryResults
  • ecr:BatchGetImage
  • ecr:GetDownloadUrlForLayer
  • codecommit:GetFile
  • codecommit:GetBlob
  • codecommit:GitPull
  • bedrock:GetAgentMemory
  • bedrock:Retrieve*

Resource: *

Two things this role can see that you should know about

  • lambda:ListFunctions, lambda:GetFunctionConfiguration and ecs:DescribeTaskDefinition are metadata calls, but AWS includes environment variables in their responses. The scanner reads the runtime, memory, architecture, code size, role and task role from them and does not read or store environment variables.
  • The deny list names specific data-plane actions. It is not a deny on every data API in every AWS service.

Write role: exact permissions

CostAgentRemediator has no managed policy. Its inline policy covers the low and medium risk actions and nothing else. There is no permission for RDS, NAT gateways, load balancers, instances or Lambda, so a high-risk change cannot be executed through this role even if the application were wrong.

Inline policy CostAgentRemediatecloudformation/cost-agent-remediator.yaml
AllowPreChecks

Re-reads the resource immediately before acting, to confirm it is still idle and still not keep-tagged.

  • ec2:DescribeAddresses
  • ec2:DescribeVolumes
  • ec2:DescribeSnapshots
  • ec2:DescribeImages
  • logs:DescribeLogGroups

Resource: *

AllowReleaseAddress

Releases an unassociated Elastic IP.

  • ec2:ReleaseAddress

Resource: arn:aws:ec2:*:<this account>:elastic-ip/*

AllowVolumeActions

Deletes an unattached volume after its snapshot, or changes a gp2 volume to gp3.

  • ec2:DeleteVolume
  • ec2:ModifyVolume

Resource: arn:aws:ec2:*:<this account>:volume/*

AllowBackupSnapshot

Takes the backup snapshot before a volume is deleted.

  • ec2:CreateSnapshot

Resource: arn:aws:ec2:*:<this account>:volume/* and arn:aws:ec2:*::snapshot/*

AllowTagSnapshotOnCreate

Tags the backup snapshot as it is created. It cannot tag anything else.

  • ec2:CreateTags

Resource: arn:aws:ec2:*::snapshot/*

Condition: ec2:CreateAction equals CreateSnapshot

AllowDeleteSnapshot

Deletes an orphaned snapshot.

  • ec2:DeleteSnapshot

Resource: arn:aws:ec2:*::snapshot/*

AllowDeregisterImage

Deregisters a stale AMI. Its snapshots are kept.

  • ec2:DeregisterImage

Resource: arn:aws:ec2:*::image/*

AllowSetLogRetention

Sets a retention period on a log group that has none.

  • logs:PutRetentionPolicy

Resource: arn:aws:logs:*:<this account>:log-group:*

DenyDenyKeepTagged

Every write action above is denied on a resource tagged Keep=true, in IAM, whatever the application does. This IAM condition covers the tag key Keep; additional keep-tag keys you configure are enforced in code.

  • ec2:ReleaseAddress
  • ec2:DeleteVolume
  • ec2:ModifyVolume
  • ec2:DeleteSnapshot
  • ec2:DeregisterImage
  • logs:PutRetentionPolicy

Resource: *

Condition: aws:ResourceTag/Keep equals true, ignoring case

State reader role

CostAgentStateReader is the only role that can read an S3 object, and only Terraform state. See Terraform state for what is kept from it.

Inline policy ReadTerraformStatecloudformation/cost-agent-state-reader.yaml
AllowListStateKeys

Lists keys in the one state bucket you name.

  • s3:ListBucket

Resource: arn:aws:s3:::<StateBucketName>

Condition: s3:prefix matches <StatePrefix>*

AllowReadStateObjects

Reads objects ending in .tfstate under that prefix, and nothing else in the bucket.

  • s3:GetObject

Resource: arn:aws:s3:::<StateBucketName>/<StatePrefix>*.tfstate

AllowDecryptState

Present only if you pass the KMS key that encrypts the state objects.

  • kms:Decrypt

Resource: the key ARN you supply

Condition: kms:ViaService equals s3.<region>.amazonaws.com

What the model can and cannot access

Spendsaw uses a model on Amazon Bedrock to explain findings in plain English; the order of findings is set by code, by monthly saving. The default is Amazon Nova Lite (amazon.nova-lite-v1:0). The model never writes a command and has no general way to call AWS.

It can

  • Call six typed tools during a chat: list_findings, get_resource_detail, get_cost_breakdown, get_metric_stats, analyze_nat_gateways and analyze_data_transfer. Each returns metadata, metrics or cost totals.
  • Read, through those tools, a finding's title, resource id, type, account, region, saving, cost source, confidence, evidence metrics, tags and remediation risk.
  • Read CloudWatch statistics for AWS service namespaces (AWS/EC2, AWS/RDS and so on).

It cannot

  • Call an arbitrary AWS API. There is no general AWS tool; the earlier one was removed.
  • Read S3 objects, table items, secrets, parameters or log events. No tool does this, and the role the tools run under denies it in IAM.
  • Read custom CloudWatch namespaces. get_metric_stats refuses any namespace that is not AWS/…, because applications publish arbitrary data to custom ones.
  • Reach an account outside the tenant. A tool call naming an account that is not one of the tenant's accounts is refused.
  • Use tools at all when explaining findings. The explanation call is made with no tools attached.
  • Write, change or supply a command. Titles, savings, risk and commands are attached by code from the finding and its template. Finding ids the model invents are dropped, its explanation is cut at 700 characters, and code blocks, aws … commands and terraform apply, destroy, import or state lines in its text are replaced with a note.
  • Carry anything between tenants. A new agent is built for every request and bound to one tenant.

Prompt injection

Resource names, tags and descriptions are written by whoever can create resources in your account, so they are treated as untrusted. Before any of it reaches a prompt it is flattened to one line, stripped of control and invisible characters, truncated to 200 characters per field, and wrapped in an <untrusted_data> block with a random boundary that the system prompt declares to be data, not instructions. The live test of this, an instruction planted in a resource tag, has not been run yet.

Sources: ai/tools.py, ai/bedrock_client.py, security/sanitize.py.

What leaves your account, and where Bedrock runs

Only resource metadata, metrics and cost figures leave your account.

Leaves your accountDoes not
Resource ARNs, ids, names, tags and short descriptionsS3 object contents (one opt-in exception: Terraform state)
Configuration state: size, type, attachment, age, architectureDatabase rows and DynamoDB items
CloudWatch metric statisticsLog events
Cost Explorer totals, and billed cost per resource when you connect the Cost and Usage ReportSecret values and SSM parameter values
AWS's own recommendations for the accountLambda code, container images, repository contents
Bedrock usage: tokens per model, and which principals called or can call itBedrock prompts, responses, agent memory or knowledge-base content

What is sent to the model

For explanations: up to 60 finding rows (id, title, waste type, account, region, saving, cost source, confidence, risk, evidence), a cost summary (total and top services), and Bedrock usage by model and caller. For chat: the tenant's account ids and names, cost totals, your question, recent conversation turns, and whatever the six tools return.

Where Bedrock runs

  • The model is invoked in the account that runs Spendsaw, not in yours. Your accounts do not need Bedrock enabled.
  • The region is the Spendsaw deployment's AWS_REGION, which defaults to us-east-1.
  • The hosted example configuration uses the US cross-region inference profile for Nova Lite, which can route a request to us-east-1, us-east-2 or us-west-2.
  • In the hosted design each tenant gets its own application inference profile, tagged with the tenant id, so model cost is attributed per tenant.
  • The final model and hosted region are not yet defined. Bedrock's own handling of prompts is governed by AWS's service terms, which we do not restate here.

Other places your metadata goes, if you turn them on

  • Slack. The digest posts finding titles, amounts, account names, regions and the commands for the top actions to the channel you choose.
  • GitHub. The GitHub App reads Terraform files in the repositories you link and opens a branch and pull request there. It needs contents write, pull requests write and checks read. It never runs Terraform.
  • The free MCP server. It runs on your machine and makes no model call itself. Its results go to whichever assistant you connected it to, so that assistant's provider sees them.

Sources: cloudformation/cost-agent-readonly.yaml, ai/bedrock_client.py, infra/terraform/envs/tooling/terraform.tfvars.example, infra/terraform/modules/tenant/main.tf, delivery/slack/digest.py, iac/github_app.py, spendsaw_mcp/README.md.

How changes are constrained

The system is built so that no change happens without a person approving the exact commands, and so that the riskiest changes cannot be executed at all.

TierFindingsWhat Spendsaw will do
Low riskUnassociated Elastic IP, log group with no retention, gp2 to gp3Runs after approval
Medium riskUnattached volume, orphaned snapshot, stale AMIRuns after approval. A volume is snapshotted first. An action with no possible backup needs an explicit acknowledgement
High riskRDS, NAT gateway, load balancer, stopped instanceRecommendation only. The command is shown for reference and is never executable
  • Templates, not generated commands. Each waste type has one template, rendered in a sandbox from identifiers that must match a strict pattern (for example vol- followed by hex). Resource names and tags are never template inputs, so they cannot add arguments or a second command.
  • Tiers live in code. Editing a template cannot make a high-risk action runnable. A waste type with no tier is treated as high.
  • Approval is by a named person. The record holds who requested, who approved, when, and the command text. Approving or executing always needs an identified caller. In Slack, only user ids on your approver list can approve, and an empty list means nobody can.
  • What runs is what was approved. At execution the plan is rendered again and its hash and commands must equal the approved ones. An approval older than 72 hours is refused.
  • A pre-check runs against live AWS first. It confirms the resource still exists, is still idle (unattached, unassociated, unused), and is still not protected by a keep tag or your allow-list. If it fails, nothing is changed.
  • Dry run where the API has one. Each EC2 step is first sent with DryRun.
  • Terraform and CloudFormation resources are not changed by CLI. A resource managed in code is fixed in code. Unknown ownership is a recommendation only unless you explicitly accept that for an account with no infrastructure as code.
  • Account limits. Execution is refused in the management account and in any account that has not opted in to remediation.
  • Everything is recorded. Each request, approval, refusal, step and failure is appended to the action record.

Sources: remediation/engine.py, remediation/approvals.py, remediation/prechecks.py, delivery/slack/interactions.py, security/auth.py.

The one exception: Terraform state

To open a pull request, Spendsaw has to know whether a resource is managed by Terraform. One of the ways it finds out is by reading your Terraform state files. This is the only place the product reads an S3 object, and it is opt-in.

  • Off unless you turn it on. Nothing is read until you name a state bucket for your tenant and deploy CostAgentStateReader in the account that owns it. The read-only role cannot do this: it denies s3:GetObject*.
  • What is kept. For each managed resource instance: its address, type, name, module, index, provider, id and arn, plus the bucket and key it was found in.
  • What is discarded. Every other attribute. State files hold secrets such as database passwords and private keys in resource attributes; the parser drops them before returning and they are never stored, logged or put in an error message.
  • Never sent to the model. The resulting index is used only to match a finding's ARN or id.
  • Limits. Only Terraform state version 4 is parsed. A file larger than 50 MB is refused, and at most 200 state files are read under one prefix.
  • If it cannot read a file, the index is marked incomplete. An incomplete index can show that a resource is Terraform-managed but is never used to conclude that it is not.

Sources: iac/state.py, iac/ownership.py, iac/config.py, cloudformation/cost-agent-state-reader.yaml, app.py.

Tenant isolation, as designed

This describes the hosted service as the Terraform modules define it. It is deployed, and it was tested live once with a second tenant; see status.

  • Every tenant-scoped DynamoDB table (findings, ledger, approvals, billing, tenants) has tenant_id as its partition key.
  • Each worker invocation serves exactly one tenant. It assumes a tenant session role with a single session tag, tenant_id, and does all of its work in that session.
  • That session's policy conditions every DynamoDB item action on dynamodb:LeadingKeys equal to the session's tenant_id tag. A query that forgets its tenant filter is denied by IAM. Scan is not granted, because it cannot be constrained by key.
  • The session can read only its own tenant's secrets (the ExternalId), and can invoke Bedrock only through an inference profile tagged with the same tenant id.
  • Scan sessions can assume only CostAgentReadOnly in other accounts. API sessions can assume only CostAgentRemediator, and only when remediation is enabled for the deployment.
  • In the API, the tenant is never taken from the request. It comes from the verified token.
  • In the application, every storage read and write names a tenant, and cross-account sessions are cached per tenant.

What this does not cover. The worker chooses the tag value from its event, so a compromised worker could open a session for any tenant. The price cache table is shared between tenants; it holds public list prices only.

Sources: infra/terraform/modules/tenant_session_role/main.tf, infra/terraform/modules/tables/main.tf, infra/README.md, workers/common.py, workers/api.py, core/storage.py.

Data retention

This table states only what the code and infrastructure set today. Where no period is set, it says so.

DataWhat is set today
FindingsReplaced on each scan. A finding that a successful scan of its account and region no longer sees is deleted. No time-based expiry is set.
Approval and action recordsNo expiry is set. The table supports an expiry attribute, but the code does not write one. Retention period: not yet defined.
Savings ledgerNo expiry is set. Retention period: not yet defined.
Tenant record and ExternalId secretKept while the tenant exists. Deletion on offboarding: not yet defined.
Subscription state and billing eventsBilling code exists and is not live. Subscription state has no expiry set. Ids of processed payment-provider webhook events expire after 7 days.
Chat conversations (self-hosted dashboard)7 days, and at most the last 40 messages.
Price cache7 days. Public AWS list prices, not customer data.
Worker, state machine and API access logs30 days by default.
Failed scan messages (dead-letter queue)14 days.
Database backupsPoint-in-time recovery and encryption at rest are enabled on the tenant tables. The recovery window is not set in the infrastructure code.
Analysis history (self-hosted dashboard)Markdown files on the deployment's own disk. No expiry is set.
MCP server cache6 hours by default, on your own machine. It holds resource ids, names and tags.
Backup snapshots taken before a deleteCreated in your account and tagged CostAgentBackup=true. They stay until you delete them; no expiry is set.
Bedrock invocation logsNot yet defined. Requests carry the tenant id as metadata so logs can be split by tenant if logging is enabled.

An approval is valid for 72 hours and a Slack snooze lasts 7 days. Those are time limits on actions, not retention periods.

Sources: core/storage.py, findings/store.py, workers/scan.py, app.py, pricing/rates.py, spendsaw_mcp/scan.py, remediation/templates/unattached_volume.json.j2, billing/stripe_routes.py, infra/terraform/modules/tables/main.tf, infra/terraform/modules/scanner/, infra/terraform/envs/tooling/variables.tf.

What we do not claim

  • No SOC 2 report, ISO certification or other attestation. Readiness work has started; an audit has not.
  • No independent penetration test.
  • No proof yet that the remediator policy behaves as written against live AWS: it has never been deployed. The read-only role and tenant isolation have been exercised live only in our own organization.
  • No retention schedule, deletion process, incident response commitment or uptime commitment. Each is not yet defined.
  • No legal terms in force. The terms of service, privacy policy and data processing addendum are unreviewed drafts.

Questions about any of this: hello@spendsaw.com.