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 blockcontrolsguardrailsvalidation, rate_limitsrate_limit, inference_table_config / usage_tracking_configobservability
environment_vars / external_modelegress — the model provider (openai, anthropic, …)
databricks_grantsreachSELECTdatabase_read; MODIFY / ALL_PRIVILEGESdatabase_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 agentagent_name, foundation_model (vendor → provider: anthropic, cohere, mistral, always bedrock), agent_resource_role_arn (where its own reach starts)
guardrail_configurationcontrolvalidation; the referenced aws_bedrock_guardrail is named with the policies it carries (content, sensitive information, topic, word, grounding)
aws_bedrock_model_invocation_logging_configurationcontrolobservability, account-wide, credited to every Bedrock agent in the module
aws_bedrockagent_agent_action_grouptoolstool_calling; a Lambda executor is followed to its role; RETURN_CONTROL is noted as tools running in application code; AMAZON.CodeInterpretercode_execution
aws_bedrockagent_agent_knowledge_base_associationRAG data sourcevector_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_settingsgoogle_dialogflow_cx_security_settingscontrol — redaction strategy and scope → validation; retention window recorded; insights export → observability
enable_stackdriver_logging, advanced_settings.logging_settingscontrolobservability
google_dialogflow_cx_generative_settingscontrol — banned phrases → validation; the generative model is recorded
google_dialogflow_cx_webhooktoolstool_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_settingsRAGvector_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.