Risk OS Red Try Stoa

Infrastructure as code

Some agents are never written — they are configured. On a managed platform such as Databricks Model Serving or Amazon Bedrock, the platform runs the loop; the "agent" is a resource block in a Terraform file that says which model to run, what it may reach, and which controls sit in front of it. A code scanner walks straight past it, because there is no AgentExecutor(, no requests.post, nothing.

The IaC collector reads that resource block instead. It runs inside the ordinary stoa scan — .tf is simply a scanned language now — and emits the agent as a candidate that flows through the same registry, report, dimension scoring, declarations, and drift as an agent found in code.

Scope of this version: Terraform (.tf, .tfvars, or a terraform show -json plan); Databricks Model Serving, Amazon Bedrock Agents, Google Dialogflow CX / Vertex AI Agent Builder. Everything else Stoa promises still holds — static, local, no terraform binary or provider plugins at scan time, deterministic output, additive schema, "observed / not observed" language.

Why the IaC layer is strong evidence

The cloud made the operator write down, explicitly, what the code scanner can only infer:

Where code gives Stoa a heuristic, infrastructure gives it a declaration.

Module scope

Terraform treats every .tf file in a directory as one configuration, and real deployments split an agent across files: the agent in agents.tf, its roles in iam.tf, the guardrail in guardrails.tf. Stoa reads the module — all .tf files in a directory together — and follows references between them. An agent keeps the path of the file that defines it (that is what its stable id is built from); evidence that came from another file says so: … (in infra/iam.tf).

Resolved, not guessed

Enterprise Terraform rarely writes a value down where it is used: names come from variables, roles from locals, and whole agents from a module called twice. Stoa resolves what the module itself states, and leaves everything else as an unresolved reference:

Terraform constructStoa behaviour
variable "x" { default = … }the default is the value
terraform.tfvars, *.auto.tfvarsoverride the default (they are scanned files, so a secret in a tfvars is found too)
locals { … }resolved against variables and other locals
"${var.x}-svc", "${local.y}"interpolated when every part resolves; otherwise left as written
count = var.on ? 1 : 0, x = cond ? a : bpicked once the condition resolves to a boolean
for_each = toset(…), count = nexpanded to one block per instance; agents get the instance in their symbol (type.name["key"], type.name[1])
toset, tolist, concatevaluated; any other function leaves the value unresolved
data "aws_iam_policy_document"its statements are the policy, no JSON needed
module "x" { source = "./modules/agent" … }the directory is instantiated with the call's inputs over its own defaults; each call is its own agent, symbol module.x.<type>.<name>, path inside the module, an IAC_MODULE_CALL evidence line at the call site. A directory used as a module source emits no bare, uninstantiated agents
var.x with no default, module.x.output, remote state, other data sourcesunresolved — never guessed

Or hand Stoa the plan

terraform plan -out=tfplan && terraform show -json tfplan > plan.json
stoa scan . --tf-plan plan.json

A plan carries every value fully resolved — variables, modules, for_each, functions — and Stoa reads resource links from the configuration's expression references, so a role ARN that is unknown until apply still links the agent to its policy. State output (terraform show -json on a state) works the same way. When a plan is supplied it replaces file-based agent discovery so nothing is counted twice; .tf files are still scanned for secrets. A plan has no file:line, so evidence anchors at the plan file (IAC_PLAN_SOURCE), and agents take the plan's path.

What it reads

The recognition dictionary is the IaC analog of the framework patterns used for code — keyed on resource type instead of a constructor call.

Databricks

Terraform resourceStoa concept
databricks_model_servingthe agent — a deployed serving endpoint; served_entities.entity_name is the Unity Catalog model it runs
↳ its ai_gateway blockcontrols — guardrails → validation, rate_limits → rate_limit, inference_table_config / usage_tracking_config → observability
↳ environment_vars / external_modelegress — the model provider (openai, anthropic, …)
databricks_grantsreach — SELECT → database_read; MODIFY / ALL_PRIVILEGES → database_write; catalog-wide ALL_PRIVILEGES is called out as broad reach
databricks_service_principalidentity — the principal grants are attributed through (by name stem)
databricks_vector_search_indexRAG data source (recognized; not yet attributed to an endpoint)

Amazon Bedrock

Terraform resourceStoa concept
aws_bedrockagent_agentthe agent — agent_name, foundation_model (vendor → provider: anthropic, cohere, mistral, always bedrock), agent_resource_role_arn (where its own reach starts)
↳ guardrail_configurationcontrol — validation; the referenced aws_bedrock_guardrail is named with the policies it carries (content, sensitive information, topic, word, grounding)
aws_bedrock_model_invocation_logging_configurationcontrol — observability, account-wide, credited to every Bedrock agent in the module
aws_bedrockagent_agent_action_grouptools — tool_calling; a Lambda executor is followed to its role; RETURN_CONTROL is noted as tools running in application code; AMAZON.CodeInterpreter → code_execution
aws_bedrockagent_agent_knowledge_base_associationRAG data source — vector_search
aws_iam_role_policy, aws_iam_policy + aws_iam_role_policy_attachmentreach — Allow-statement actions on the agent's role and on each tool Lambda's role, mapped to capabilities (below). Three policy forms parse: jsonencode({...}), heredoc / literal JSON, and AWS managed policy ARNs

Google — Dialogflow CX / Vertex AI Agent Builder

Terraform resourceStoa concept
google_dialogflow_cx_agentthe agent — a conversational agent built in the CX console; provider google, integration gcp
↳ security_settings → google_dialogflow_cx_security_settingscontrol — redaction strategy and scope → validation; retention window recorded; insights export → observability
↳ enable_stackdriver_logging, advanced_settings.logging_settingscontrol — observability
google_dialogflow_cx_generative_settingscontrol — banned phrases → validation; the generative model is recorded
google_dialogflow_cx_webhooktools — tool_calling; a URI referencing a Cloud Function or Cloud Run service is followed to the service account it runs as, then to every google_*_iam_member / _binding naming that account (roles → capabilities, below); a literal external URI → external_http
google_dialogflow_cx_tooltools — OpenAPI → tool_calling + external_http; function spec → tool_calling (runs in application code); data store spec → vector_search
google_dialogflow_cx_flow / _page knowledge_connector_settingsRAG — vector_search, data stores named
google_discovery_engine_chat_enginethe agent when it creates its own (agent_creation_config), platform vertex_ai_agent_builder; RAG attribution to the linked CX agent when it uses dialogflow_agent_to_link
google_discovery_engine_data_storeRAG data source
IAM rolesCapability
roles/bigquery.dataViewer / jobUser / user, datastore.viewer, cloudsql.client, spanner.databaseReaderdatabase_read
roles/bigquery.dataEditor / dataOwner / admin, datastore.user, cloudsql.editor, spanner.databaseUserdatabase_read + database_write
roles/storage.objectViewer · objectCreator · objectAdmin / objectUser / adminfilesystem_read · filesystem_write · both
roles/pubsub.publisher / editor · pubsub.subscribermessaging · queue_access
roles/run.invoker, cloudfunctions.invoker, workflows.invokertool_calling
roles/owner, roles/editor, projectIamAdmin, serviceAccountTokenCreator, compute.admincloud_resource_access (primitive roles called out as broad reach)
any other rolenothing — reported as not in the dictionary

IAM actions map to capabilities conservatively — the service says what kind of thing the tools may do, not the effective permission set:

IAM actionsCapability
dynamodb / rds / rds-data / redshift / athena … read verbsdatabase_read
… write verbs (Put*, Update*, Delete*, Execute*, *)database_read + database_write
s3:Get* / List* · s3:Put* / Delete* / *filesystem_read · filesystem_write
ses:Send* · sns:Publish · sqs:Send*email_send · messaging · queue_access
lambda:Invoke*, states:Start*tool_calling
ssm:SendCommand · codecommit:*shell_execution · source_control
*, iam:*, ec2:*, cloudformation:* (non-read)cloud_resource_access
bedrock:InvokeModel, logs:*, iam:PassRole, secretsmanager:Get*, *:Describe*nothing — plumbing

Deny statements grant nothing; Condition blocks are not evaluated; a policy that arrives as a reference (data.aws_iam_policy_document.x.json, var.policy) is reported as unresolved, never guessed.

Parsing is a small zero-dependency HCL block extractor: nested and repeated blocks, lists, references, heredocs, and the object syntax inside jsonencode(...) are understood; a value the module does not resolve (var.model, remote state) is left unresolved, never guessed.

What it emits

Each agent becomes a candidate with:

Controls stated by infrastructure are credited in dimension_assessment through the same control_credit math as code-observed controls — attributed per agent, through its own gateway or guardrail, never by pattern-matching the shared file (which would credit one agent's rate limit to another).

Secrets are scanned too: a literal API key in a .tf is caught and redacted exactly as in code.

Three worked examples

The Tidewater fixture is a fictional payments company with two Databricks-hosted agents, planted for contrast:

files scanned: 3        # + infra/main.tf
agents found:  4        # 2 from code, 2 from infrastructure

support_agent  (iac)  reach: database_read (one SELECT grant)
                      controls credited: validation · rate_limit · observability
refund_agent   (iac)  reach: database_read + database_write
                      (ALL_PRIVILEGES on prod, MODIFY on refunds)
                      controls credited: none        egress: openai

The Kestrel fixture is a freight company with two Bedrock agents spread across five .tf files and no model call anywhere in its Python:

files scanned: 7        # 5 .tf + 2 Lambda handlers
agents found:  2        # both from infrastructure, 0 from code

claims_assistant  reach: database_read · tool_calling · vector_search
                  controls credited: validation (guardrail) · observability (logging)
                  mandate-overreach 0
dispatch_agent    reach: database_write · email_send · messaging · filesystem_write
                         · code_execution · …    (dynamodb:* on *, SES, SNS, AmazonS3FullAccess,
                         code interpreter — all through its tool Lambda's role)
                  controls credited: observability only
                  mandate-overreach 90

The Marlowe fixture is a utility whose Dialogflow CX agents are deployed through one local module called twice; the whole contrast lives in the module inputs, locals, a tfvars override, count = var.x ? 1 : 0 and for_each = toset(concat(…)):

files scanned: 6        # 5 .tf + terraform.tfvars
agents found:  3        # two module instances + a Vertex AI Agent Builder chat engine

module.billing_assistant  reach: database_read · tool_calling · vector_search
                          controls credited: validation (redaction, retention) · observability
                          mandate-overreach 0
module.field_service      reach: cloud_resource_access · database_write · messaging · …
                                 (roles/editor on project marlowe-prod — primitive role, broad reach)
                          controls credited: none
                          mandate-overreach 54
knowledge_assistant       reach: vector_search over billing-policies

In all three, the infrastructure alone separates the well-fenced agent from the poorly-fenced one — and because they are ordinary agents, drift sees them too: widening a grant from SELECT to ALL_PRIVILEGES, or a Lambda policy from two read actions to dynamodb:*, is database_write gained, a high-impact capability, and stoa diff reports it as high drift.

Configuration

[iac]
enabled = true     # default; off => .tf files are still scanned for secrets but emit no agents

.tf is included by default via include_extensions; see Configuration.

Honest limits of this version

What comes next

In order of value: an IAC rule family so that a missing guardrail or security setting, a catalog-wide grant, a wildcard IAM action, or a primitive GCP role moves exposure (with crosswalk entries); the IaC → code join (entity_name to the registering model on Databricks; action-group Lambda or webhook function to its handler), which hands the taint scanner the code and upgrades the tier to full; Amazon Lex and Bedrock Flows; Azure OpenAI / AI Foundry rows; and Asset Bundles as a second input.