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
401without 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 atsts:AssumeRole, read 0 findings and got404for 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.
| Role | Needed | What it is for |
|---|---|---|
CostAgentReadOnly | Always | Scanning. Metadata, metrics and cost totals only, with an explicit deny on reading your data. |
CostAgentRemediator | Opt-in | Running approved low and medium risk actions. Deployed only in accounts where you want remediation. Never in the management account. |
CostAgentStateReader | Opt-in | Reading 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. TrustedRoleNamenarrows the trust to one role in the trusted account. If you leave it empty, any principal in that account that holdssts:AssumeRoleand your ExternalId can assume the role.- The application passes the tenant's ExternalId on every
AssumeRolecall.
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.
CostAgentScancloudformation/cost-agent-readonly.yamlCost 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: *
CloudWatch metric statistics, used as the evidence that a resource is idle. Metrics, not logs.
- cloudwatch:GetMetric*
- cloudwatch:ListMetrics
Resource: *
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: *
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: *
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: *
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: *
Lists REST and HTTP APIs. Limited to the two list paths.
- apigateway:GET
Resource: arn:aws:apigateway:*::/restapis and arn:aws:apigateway:*::/apis
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:GetFunctionConfigurationandecs:DescribeTaskDefinitionare 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.
CostAgentRemediatecloudformation/cost-agent-remediator.yamlRe-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: *
Releases an unassociated Elastic IP.
- ec2:ReleaseAddress
Resource: arn:aws:ec2:*:<this account>:elastic-ip/*
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/*
Takes the backup snapshot before a volume is deleted.
- ec2:CreateSnapshot
Resource: arn:aws:ec2:*:<this account>:volume/* and arn:aws:ec2:*::snapshot/*
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
Deletes an orphaned snapshot.
- ec2:DeleteSnapshot
Resource: arn:aws:ec2:*::snapshot/*
Deregisters a stale AMI. Its snapshots are kept.
- ec2:DeregisterImage
Resource: arn:aws:ec2:*::image/*
Sets a retention period on a log group that has none.
- logs:PutRetentionPolicy
Resource: arn:aws:logs:*:<this account>:log-group:*
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.
ReadTerraformStatecloudformation/cost-agent-state-reader.yamlLists keys in the one state bucket you name.
- s3:ListBucket
Resource: arn:aws:s3:::<StateBucketName>
Condition: s3:prefix matches <StatePrefix>*
Reads objects ending in .tfstate under that prefix, and nothing else in the bucket.
- s3:GetObject
Resource: arn:aws:s3:::<StateBucketName>/<StatePrefix>*.tfstate
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_gatewaysandanalyze_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/RDSand 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_statsrefuses any namespace that is notAWS/…, 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 andterraform apply,destroy,importorstatelines 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 account | Does not |
|---|---|
| Resource ARNs, ids, names, tags and short descriptions | S3 object contents (one opt-in exception: Terraform state) |
| Configuration state: size, type, attachment, age, architecture | Database rows and DynamoDB items |
| CloudWatch metric statistics | Log events |
| Cost Explorer totals, and billed cost per resource when you connect the Cost and Usage Report | Secret values and SSM parameter values |
| AWS's own recommendations for the account | Lambda code, container images, repository contents |
| Bedrock usage: tokens per model, and which principals called or can call it | Bedrock 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 tous-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-2orus-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.
| Tier | Findings | What Spendsaw will do |
|---|---|---|
| Low risk | Unassociated Elastic IP, log group with no retention, gp2 to gp3 | Runs after approval |
| Medium risk | Unattached volume, orphaned snapshot, stale AMI | Runs after approval. A volume is snapshotted first. An action with no possible backup needs an explicit acknowledgement |
| High risk | RDS, NAT gateway, load balancer, stopped instance | Recommendation 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
CostAgentStateReaderin the account that owns it. The read-only role cannot do this: it deniess3:GetObject*. - What is kept. For each managed resource instance: its address, type, name, module, index, provider,
idandarn, 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_idas 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:LeadingKeysequal to the session'stenant_idtag. A query that forgets its tenant filter is denied by IAM.Scanis 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
CostAgentReadOnlyin other accounts. API sessions can assume onlyCostAgentRemediator, 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.
| Data | What is set today |
|---|---|
| Findings | Replaced 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 records | No expiry is set. The table supports an expiry attribute, but the code does not write one. Retention period: not yet defined. |
| Savings ledger | No expiry is set. Retention period: not yet defined. |
| Tenant record and ExternalId secret | Kept while the tenant exists. Deletion on offboarding: not yet defined. |
| Subscription state and billing events | Billing 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 cache | 7 days. Public AWS list prices, not customer data. |
| Worker, state machine and API access logs | 30 days by default. |
| Failed scan messages (dead-letter queue) | 14 days. |
| Database backups | Point-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 cache | 6 hours by default, on your own machine. It holds resource ids, names and tags. |
| Backup snapshots taken before a delete | Created in your account and tagged CostAgentBackup=true. They stay until you delete them; no expiry is set. |
| Bedrock invocation logs | Not 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.