A Go implementation of the CloudSecurity-AF cloud-infrastructure security scanner.
It registers the same reasoner surface under the same names as the Python node,
exposes a byte-compatible HTTP API, and drives the same control-plane DAG, so the
UI renders the identical multi-phase orchestration graph (see
Pipeline DAG on the control plane).
The Python package under src/cloudsecurity_af/ is untouched; this
implementation lives entirely under go/.
One binary:
| Binary | Node ID | Default port | Role |
|---|---|---|---|
cloudsecurity-af |
cloudsecurity |
8015 |
Full scan pipeline (scan and prove) |
Module path: github.com/Agent-Field/cloudsecurity-af/go.
Node id vs package name. The package is named
cloudsecurity-af; the node it registers iscloudsecurity. That is the Python node's behaviour too (NODE_ID = os.getenv("NODE_ID", "cloudsecurity")inapp.py), so calls arecloudsecurity.scan/cloudsecurity.prove. Both manifests — the root one andgo/agentfield-package.yaml— declarenode_id: cloudsecurity, the id the process actually registers;go/packaging_test.gofails the build if either drifts fromapp.py's default.The two names are not interchangeable on the command line:
af runandaf logstake the PACKAGE name (cloudsecurity-af, the registry key), whileaf calltakes the NODE id (cloudsecurity.scan).
Installing the bare repository URL follows the root manifest's redirect to this
package, so the process you get is the Go node, registered as cloudsecurity:
af install https://github.com/Agent-Field/cloudsecurity-af
af run cloudsecurity-af # the PACKAGE name — `af run cloudsecurity` is "not installed"
af call cloudsecurity.scan --in '{"repo_url":"https://github.com/org/infra-repo"}'af run picks a free port starting at 8001 and exports it as PORT, so an
installed node does not land on 8015 — pass --port 8015 if you want it
there. 8015 is the binary's own default (a bare go run ./cmd/cloudsecurity-af
or make run) and what docker-compose.go.yml sets explicitly.
To install the Python node deliberately, clone the repository and run
af install ./cloudsecurity-af; local-path installs do not follow the redirect.
NODE_ID / PORT still override the Go defaults if you want a different
id/port.
Two externally driven reasoners, exactly as src/cloudsecurity_af/app.py:
| Reasoner | Tier | Builds |
|---|---|---|
scan |
1 | static IaC scan; cloud=None |
prove |
2 | live scan; CloudConfig(provider, regions=["us-east-1"], assume_role_arn) |
Both accept the same pipeline knobs (depth, severity_threshold,
output_formats, compliance_frameworks, max_cost_usd,
max_duration_seconds, include_paths, exclude_paths, is_pr,
fail_on_findings). scan additionally takes base_commit_sha, pr_id and
the two concurrency caps; prove additionally takes cloud_provider,
cloud_regions and assume_role_arn. The exact parameter list is published to
the control plane as each reasoner's input schema.
Behind them are the 20 router reasoners (tagged cloud, security,
infrastructure): four recon agents, seven hunters, the path constructor, the
two provers, the fix generator, and the five *_phase drivers.
A scan is not one execution. scan/prove build a ScanOrchestrator, which
calls the five phase reasoners through the control plane, and each phase calls
its own agents the same way — so the run renders as this graph:
scan / prove
├── recon_phase
│ ├── run_iac_reader
│ ├── run_resource_graph_builder (after iac_reader)
│ ├── run_cloud_connector ┐ gather (2) — only tier >= 2 with a cloud config
│ └── run_drift_detector ┘
├── hunt_phase
│ └── run_<hunter>_hunter × 5 (quick) / 7 (standard, thorough) semaphore max(1,min(3,N))
├── chain_phase
│ └── run_path_constructor
├── prove_phase
│ └── run_static_prover × K (tier < 2) | run_live_prover × K semaphore 3
└── remediation_phase
└── run_fix_generator × M
Every arrow is an Agent.Call(ctx, "<NODE_ID>.<reasoner>", kwargs) with the
same target name and kwargs the Python node uses, so the node/edge multiset is
identical between the two implementations. The handler's ctx is threaded
through unchanged — that is what parents the child execution under the scan
execution.
This module depends on the AgentField Go SDK
(github.com/Agent-Field/agentfield/sdk/go) via a real, committed require
resolved from proxy.golang.org — there is no replace directive and no
sibling checkout to lay out. go build ./... works out of the box against the
pinned SDK version in go.mod.
- CI / Docker.
go mod downloadpulls the SDK (and every other dependency) straight from the module proxy. - Dev — optional Go workspace. A gitignored
go.work(spanning this module and a localagentfield/sdk/gocheckout) is the way to develop against unreleased SDK changes. It is never committed.
The one non-SDK third-party dependency is github.com/hashicorp/hcl/v2, which
replaces Python's pyhcl2 in the deterministic Terraform parser.
From go/:
make build # go build ./...
make vet # go vet ./...
make test # go test ./...
make fmt-check # test -z "$(gofmt -l .)"
make check # all four — the CI gate
make fmt # gofmt -w .
make run # run the node (cloudsecurity, :8015)make run needs a control plane reachable at AGENTFIELD_SERVER (default
http://localhost:8080). The node reads all configuration from the environment
at startup.
Multi-stage build: a checksum-verified AForge CLI fetch (the same stage the
Python Dockerfile uses), a Go builder, and a slim Debian runtime with the
pinned opencode CLI, a non-root cloudsecurity user, and the single static
binary at /usr/local/bin/cloudsecurity-af. docker-entrypoint.sh generates
opencode.json at container start from CLOUDSECURITY_MODEL, falling back to
HARNESS_MODEL and then to the image default — config.py's precedence chain,
so the model env vars are actually honoured (the Python image bakes a fixed
config instead). CLOUDSECURITY_MODEL is the one to reach for inside the
image, since the Dockerfile already sets HARNESS_MODEL.
The build context is the repo root so the go/ module is in context:
# from the repo root
docker build -f go/Dockerfile -t cloudsecurity-af-go:latest .The tag is cloudsecurity-af-go. The root README's
docker build ... -t cloudsecurity-af . builds the Python image and
resolves to cloudsecurity-af:latest, so sharing the tag would make whichever
image was built last silently own it — and the two differ (port 8005 vs 8015, a
different entrypoint). make docker-build and CI use the same -go tag.
docker-compose.go.yml (at the repo root) is an add-on, not a standalone
stack. It defines only the Go node and joins the Python stack's compose network
as an external reference, sharing the control plane (agentfield). The Python
docker-compose.yml is left untouched. Start the Python stack first, then layer
the Go node:
docker compose up -d # Python stack (control plane + cloudsecurity-af :8005)
docker compose -f docker-compose.go.yml up -d # adds cloudsecurity-go :8015Adds:
| Service | Port | Node id | Notes |
|---|---|---|---|
cloudsecurity-go |
8015 |
cloudsecurity-go |
full scan pipeline |
The workspaces directory is a host bind mount, not a named volume — the
Python compose mounts ${SCAN_REPOS_PATH:-./workspaces} into /workspaces and
the add-on mirrors that exact bind (same variable, same default). Both nodes
resolve a given repo_url to the same checkout only when that directory is
writable by uid 10001, the user both images run as: with the default
./workspaces, Docker auto-creates the bind target owned by the host uid, so
app.py::_workspaces_root's write probe fails and each node falls back to its
own container-local ~/.sec-af/workspaces. Set SCAN_REPOS_PATH to a
directory uid 10001 can write if you want one shared clone.
The control plane (:8080 inside the network) comes from the Python stack via
the external cloudsecurity-af_default network; this assumes the Python stack
was brought up with the default project name cloudsecurity-af (its compose
has no explicit name:, so the project name is the checkout directory's
basename). See the compose file header for the COMPOSE_PROJECT_NAME override.
Health: curl -f http://localhost:8015/health.
The node is configured entirely through the environment.
| Variable | Purpose |
|---|---|
OPENROUTER_API_KEY |
LLM provider key (OpenRouter) — required |
AGENTFIELD_SERVER |
Control-plane URL (default http://localhost:8080) |
AGENTFIELD_API_KEY |
Control-plane API key (if the CP has auth enabled) |
AGENT_CALLBACK_URL |
Base URL the CP uses to reach this node; unset → http://localhost:<PORT> |
NODE_ID |
Node ID (default cloudsecurity) |
PORT |
Listen port (default 8015) |
HARNESS_PROVIDER |
Harness provider (default aforge; opencode to roll back). CLOUDSECURITY_PROVIDER wins over it |
AGENTFIELD_AFORGE_COMMAND |
AForge headless command — exec (default) or do |
HARNESS_MODEL |
Harness model. CLOUDSECURITY_MODEL wins over it |
AI_MODEL |
Model for direct .ai() calls. CLOUDSECURITY_AI_MODEL wins over it |
CLOUDSECURITY_MAX_TURNS |
Harness turn cap (default 50); a malformed value fails the boot |
CLOUDSECURITY_OPENCODE_BIN |
opencode executable (default opencode) |
CLOUDSECURITY_AFORGE_BIN |
aforge executable (default aforge; AFORGE_BIN is the fallback) |
SEC_AF_WORKSPACES_DIR |
Clone root for remote repo_url values (default /workspaces, falling back to ~/.sec-af/workspaces) |
CLOUDSECURITY_REPO_PATH |
Repo path used when repo_url is neither a directory nor a URL |
XDG_DATA_HOME |
Data home forwarded to the harness (default <tmpdir>/opencode-shared-data). The Go image and docker-compose.go.yml set it to /home/cloudsecurity/.local/share, backed by the opencode-data volume; the Python image leaves it unset — see divergence 6 |
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN / AWS_REGION / AWS_DEFAULT_REGION |
Read-only AWS credentials forwarded to the harness |
GOOGLE_APPLICATION_CREDENTIALS |
GCP credentials forwarded to the harness |
AZURE_CLIENT_ID / AZURE_CLIENT_SECRET / AZURE_TENANT_ID / AZURE_SUBSCRIPTION_ID |
Azure credentials forwarded to the harness |
Note: the code default model is openrouter/minimax/minimax-m2.5, while the
Docker image / compose / manifest set
HARNESS_MODEL=deepseek/deepseek-v4-flash-0731. The env var always wins; both
defaults are intentional (they mirror the Python node).
Everything else is a 1:1 port (same concurrency bounds, same result key sets, same error strings, and prompt text that is byte-identical apart from item 7 below). These are on purpose and are commented at the source:
- Callback URL. Python hardcodes the fallback
http://host.docker.internal:8020— a Docker-Desktop-only host name, on the wrong port (the node listens on 8005). The Go node leavesPublicURLempty whenAGENT_CALLBACK_URLis unset, so the SDK useshttp://localhost:<listen port>, which is correct on bare metal. Every container deployment setsAGENT_CALLBACK_URLexplicitly anyway. - AI config. The Go SDK's
ai.Configrejects an empty API key at construction, soAIConfigis attached only whenOPENROUTER_API_KEYis set; and theopenrouter/routing prefix is stripped for the direct API call (Python's LiteLLM consumes that prefix itself). The harness model keeps the prefix. - Failure diagnostics. Python prints
SCAN ERROR: <exc>plus a traceback before returning 500; Go prints the same line without a traceback (Go error values carry no stack). - Number literals lost by the SDK decoder. The Go SDK decodes a reasoner
body with a plain
encoding/jsondecoder (noUseNumber), so every JSON number reaches the handler asfloat64and the literal's spelling is gone before the port sees it. Two consequences, both narrow: an integral float in a free-formAnyfield (DriftedResource.iac_config,ConfigDiff.iac_value) re-renders as7, where Python keeps7.0(internal/afx/bind.go); and a number handed to astrparameter is rendered with the integer spelling, so{"depth": 4}gives Python's"4"while{"depth": 4.0}gives"4"where Python gives"4.0"(internal/afx/handlerinput.go). Both trade the rarer reading for the common one — the alternative turns every{"port": 5432}into5432.0. - Map key order. A Go map has no insertion order, so
pyfmt.Dumpssorts keys where a Python dict would keep the order it was built in. The two fields whose Python order is fixed and knowable —by_severity(theSeverityenum order) andcost_breakdown(_PHASE_ORDER) — are declared inschemas.BySeverityOrder()/schemas.CostBreakdownOrderand rendered in that order by every renderer, including thescan/provereply. Onlymetadata, whose keys are assembled ad hoc, is still sorted. XDG_DATA_HOMEin the image. The GoDockerfilesetsXDG_DATA_HOME=/home/cloudsecurity/.local/shareanddocker-compose.go.ymlbacks it with the named volumeopencode-data; the Python image and compose set neither, soprovider_env()falls back to<tmpdir>/opencode-shared-data— container-local and lost on restart. The node CODE is a 1:1 port (internal/config'sProviderEnvreads the same variable with the same fallback); only the packaging differs, so the harness data home survives a container restart on the Go side.- Key order in the harness prompt's JSON Schema block. Python appends
json.dumps(Model.model_json_schema(), indent=2)to everyapp.harness(..., schema=Model)prompt, which preserves pydantic's field declaration order. The Go path is amap[string]anyend to end —harnessx.SchemaFor[T]()decodes the committed fixture into one and the SDK renders it withjson.MarshalIndent, which sorts map keys — so the block carries the same schema with the keys alphabetised. ForHuntResultthat is a 199-line diff of identical content at an identical 4174 bytes, so the SDK's 4000-token large-schema branch takes the same branch on both sides. Only the ORDER differs:harnessx.LoadEmbeddedSchemadecodes withUseNumberso pydantic's numeric literals ("default": 0.0) survive verbatim, andgo/scripts/gen_schemas.pywrites the fixtures withsort_keys=Trueso the committed file matches the prompt byte for byte (pinned byTestEmbeddedSchemas_AreKeySortedLikeTheSDKWillRenderThem). The order itself is not fixable inside this port — bothagent.Harnessandharness.BuildPromptSuffixtake amap[string]any, so an order-preserving schema type would have to come from the SDK. - pydantic scalar coercion the SDK harness cannot reproduce.
afx.Bindports pydantic v2's lax scalar ladder (internal/afx/lax.go), so every portedmodel_validateaccepts what Python accepts. The Go SDK's HARNESS validation does not: it decodes the model reply with a plainjson.UnmarshalAND validates it against the committed pydantic schema, so a model that writes"iac_line": "12"or"security_relevant": "true"burns the schema-retry budget and ends as a harness error, where the Python node'sschema.model_validate(data)accepts it on the first attempt. Closing this needs a change insdk/go/harness. - Non-finite floats. Python's
float()and pydantic both accept"NaN","Infinity","-inf"and the overflowing"1e999"formax_cost_usd, and the scan then runs (every budget comparison against a non-finite number is False). JSON has no literal for a non-finite number andencoding/jsonrefuses to marshal one, so Go rejects those four spellings with the ordinary "cannot unmarshal string ... into float64" (afx.pyFloat). Any finite spelling — including"2.5"— is coerced exactly as pydantic does.
go test ./... covers the port end to end: golden tests compare every prompt
builder byte-for-byte against the Python originals (scripts/gen_golden.py
regenerates the fixtures with the Python interpreter), schema fixtures under
internal/harnessx/testdata/schemas/ are the real model_json_schema() output,
the phase tests assert the exact .call targets, kwargs and semaphore bounds,
and the node tests pin the 22-reasoner registration surface and the 400/500
error mapping.