Skip to content

Endpoints: Analyzers & Rules

List all registered analyzer backends.

Response 200

{
"analyzers": [
{"name": "terraform-plan", "description": "Analyses terraform plan output…"}
]
}

Requires admin role. Every registered analyzer with this org’s enabled/disabled override — an analyzer with no override on file is enabled by default.

Response 200

{
"analyzers": [
{
"name": "terraform-plan",
"description": "Analyses terraform plan output…",
"version": "1.0.0",
"file_globs": [],
"enabled": true
}
]
}

POST /orgs/{org_id}/analyzers/{analyzer_name}

Section titled “POST /orgs/{org_id}/analyzers/{analyzer_name}”

Requires admin role. Enable or disable a registered analyzer for this org. A disabled analyzer’s rules/ingest/analyze endpoints return 404 for this org — the org simply can’t see it, not “forbidden.”

Request body

{"enabled": false}

Response 200

{"analyzer_name": "terraform-code-change", "enabled": false}
StatusDescription
200Override saved
404analyzer_name isn’t a registered analyzer

DELETE /orgs/{org_id}/analyzers/{analyzer_name}

Section titled “DELETE /orgs/{org_id}/analyzers/{analyzer_name}”

Requires admin role. Shorthand for POST with {"enabled": false}.

StatusDescription
200Analyzer disabled
404analyzer_name isn’t a registered analyzer

Return the Markdown security ruleset for an analyzer.

ParameterDescription
analyzer_nameRegistered analyzer name (e.g. terraform-plan)
ParameterTypeDescription
typestring (optional)Sub-category filter (e.g. aws, gcp)
StatusDescription
200Markdown text (text/markdown)
404Analyzer not found, or no rule file for the requested type
501Analyzer does not support rules yet
Terminal window
curl http://localhost:8000/terraform-plan/rules
curl http://localhost:8000/terraform-plan/rules?type=aws

Parse raw input and split it into analysis units.

ParameterDescription
analyzer_nameRegistered analyzer name
namespaceLogical grouping for this analysis run (e.g. prod-deploy-42)

Request bodytext/plain, raw plan output (max 10 MB)

Response 201

[
{
"hash": "a3f1c2d4e5b6",
"type": "aws_db_instance",
"name": "orders",
"action": "create",
"body": "resource \"aws_db_instance\" \"orders\" {\n storage_encrypted = false\n}",
"metadata": {"resource_address": "aws_db_instance.orders"}
}
]
FieldTypeDescription
hashstringStable content hash — use this key for analysis submission
typestringTerraform resource type
namestringResource name from the plan
actionstringcreate / update / destroy / no-op
bodystringFull resource block (content to analyse)
metadataobjectParser-level metadata
StatusDescription
201Ingested — returns unit list
404Unknown analyzer
413Input > 10 MB
422Empty body
501Analyzer does not support ingestion

Server-side analysis: ingest and analyze in one call, instead of driving the get_next_resource/submit_analysis loop yourself. The server calls the LLM provider directly.

ParameterDescription
analyzer_nameRegistered analyzer name
namespaceLogical grouping for this analysis run

Request bodyapplication/json

{
"llm_backend": "anthropic",
"model": "claude-sonnet-5",
"api_key": "sk-...",
"raw_input": "Terraform will perform the following actions:\n\n # aws_db_instance.orders will be created\n ...",
"rule_type": "aws"
}
FieldTypeDescription
llm_backendstringanthropic, mistral, or openai
modelstringProvider-specific model name
api_keystring | nullLLM provider API key. Omit to fall back to a credential stored via POST /orgs/{org_id}/llm-credentials
raw_inputstringSame raw text you’d send to .../ingest
rule_typestring | nullOptional rule sub-category, e.g. aws

Small inputs (at most server.sync_analysis_max_units, default 5, ingested units) run inline:

Response 200 (sync)

{"report_markdown": "# Analysis Report\n...", "resource_count": 3, "quota_exceeded": false}

Larger inputs are processed as a background job:

Response 202 (async)

{"job_id": "b7e2...", "status": "queued"}

Poll GET /jobs/{job_id} for completion.

StatusDescription
200Analysis complete (sync) — full report
202Job queued (async) — poll for status
400No api_key given and none stored for this org/provider, or (llm_backend, model) isn’t on the allowed-models catalog
402Org’s monthly LLM spend cap already met/exceeded — checked before any work starts, see Billing / quota
404Unknown analyzer, or analyzer disabled for this org
413Input exceeds server.max_input_size_mb
501Analyzer does not support ingestion
502LLM provider returned output that couldn’t be parsed (sync path only)

Like POST /{analyzer_name}/{namespace}/ingest, but without naming an analyzer — each file in a unified diff is routed to whichever registered analyzer’s file_globs matches its path; a single non-diff blob is matched via each analyzer’s optional sniff() hook. Used by the GitHub webhook internally, and available directly for any caller that doesn’t already know which analyzer applies (e.g. a PR diff that may touch several file types).

ParameterDescription
namespaceLogical grouping for this analysis run

Request bodytext/plain, raw diff or single-file content (max 10 MB)

Response 201

{
"units": [{"hash": "a3f1c2d4e5b6", "type": "aws_db_instance", "name": "orders", "action": "create", "body": "...", "metadata": {"analyzer": "terraform-code-change", "...": "..."}}],
"analyzers_used": ["terraform-code-change", "blackhole"],
"detections": [
{"path": "main.tf", "detected_kind": "hcl", "analyzer": "terraform-code-change", "confidence": 1.0, "reason": null, "fallback": false},
{"path": "README.md", "detected_kind": "markdown", "analyzer": "blackhole", "confidence": 0.0, "reason": "no analyzer available for this file type", "fallback": true}
]
}
FieldTypeDescription
unitsarraySame shape as .../ingest’s response, merged across every matched analyzer
analyzers_usedstring[]Analyzers actually invoked
detectionsarrayOne entry per file (diff input) or one entry for the whole blob (non-diff input) — analyzer: null means nothing matched, with reason explaining why (no analyzer recognises this content, the matching analyzer is disabled for this org, or it requires a paid plan); fallback: true means analyzer is the generic blackhole analyzer rather than a real match — see below
StatusDescription
201At least one file/blob matched an analyzer
413Input > 10 MB
422Empty body, or nothing matched any analyzer (detections explains why) — cannot happen when blackhole is installed and enabled, since it always claims whatever nothing else does

Server-side counterpart to POST /{namespace}/ingest/auto — same auto-routing, plus the LLM call, same sync-vs-background-job split as POST /{analyzer_name}/{namespace}/analyze.

ParameterDescription
namespaceLogical grouping for this analysis run

Request body — same as .../analyze, without rule_type: each matched analyzer always uses its own default_rule_type — a single variant override doesn’t mean the same thing across different analyzers in one run.

Response 200/202 — same as .../analyze, plus analyzers_used/detections (sync response) or the same two fields on the polled GET /jobs/{job_id} document (async).

{"report_markdown": "# Analysis Report\n...", "resource_count": 3, "quota_exceeded": false, "analyzers_used": ["terraform-code-change"], "detections": [{"path": "main.tf", "detected_kind": "hcl", "analyzer": "terraform-code-change", "confidence": 1.0, "reason": null, "fallback": false}]}
StatusDescription
200Analysis complete (sync) — full report
202Job queued (async) — poll for status
400No api_key given and none stored, or (llm_backend, model) not on the allowed-models catalog
402Org’s monthly LLM spend cap already met/exceeded, or org not entitled
422Empty body, or nothing matched any analyzer
502LLM provider returned output that couldn’t be parsed (sync path only)

List/filter analysis jobs for the caller’s org, newest first. Covers jobs from every trigger — the dashboard’s Analyze page as well as the GitHub webhook. It’s the only place to see webhook-triggered jobs; a client that just called /analyze only knows about the one job it launched.

ParameterTypeDefaultDescription
statusstringqueued, running, done, failed, or cancelled
limitint50Page size (1–200)
offsetint0Pagination offset

Response 200

{
"jobs": [
{
"job_id": "b7e2...",
"org_id": "...",
"namespace": "prod-deploy-42",
"analyzer": "terraform-plan",
"status": "done",
"mode": "async",
"created_at": "2026-07-09T00:00:00Z",
"finished_at": "2026-07-09T00:00:05Z",
"resource_count": 14,
"quota_exceeded": false,
"error": null
}
],
"total": 1
}

Poll the status of an async /analyze job.

Response 200

{
"job_id": "b7e2...",
"org_id": "...",
"namespace": "prod-deploy-42",
"analyzer": "terraform-plan",
"analyzers_used": [],
"detections": [],
"status": "done",
"mode": "async",
"created_at": "2026-07-09T00:00:00Z",
"finished_at": "2026-07-09T00:00:05Z",
"resource_count": 14,
"processed_count": 14,
"total_count": 14,
"current_resource": {
"hash": "9f2c...",
"resource_address": "aws_s3_bucket.example",
"type": "aws_s3_bucket"
},
"quota_exceeded": false,
"error": null
}

status transitions queuedrunningdone | failed | cancelled. Once done, fetch the report via GET /{namespace}/report as usual — a cancelled job also leaves a readable report, covering whatever it analyzed before it stopped.

analyzer is the literal "auto" for a job created via /analyze/auto or the GitHub webhook — analyzers_used/detections (same shape as /{namespace}/ingest/auto’s response, see above) then carry the actual breakdown. Both are empty arrays for an explicit-analyzer job.

current_resource is the resource most recently completed by the analysis loop. The loop analyzes a batch of resources concurrently (analysis.max_concurrent_units, default 4), so while status == "running" this is one of the resources in flight rather than the only one — treat it as “roughly where the job is”, with processed_count/total_count as the exact measure. It’s null until the loop has processed at least one resource, and isn’t cleared on completion (it reflects the last resource processed).

StatusDescription
200Job found
404Unknown job, or belongs to a different org

Resumes a failed job under a new job_id — the failed job is left untouched, not reset in place. namespace, analyzer, and llm_backend/model/rule_type are read from the original job; only ingested-but-unanalysed resources are processed, the same “resources already analysed are skipped” behavior .../analyze itself has when called again on a partially-analysed namespace — a job that died partway through a large run only re-does what it never got to.

Request body (all fields optional)

FieldTypeDescription
api_keystringNever stored, same as .../analyze. Falls back to the org’s stored credential for the job’s llm_backend — the only option if the original request’s api_key was inline and never stored either.

Response 202

{ "job_id": "c91f...", "status": "queued", "retry_of": "b7e2..." }
StatusDescription
202Retry started — poll GET /jobs/{job_id} (the new one) as usual
400No api_key given and none stored, or (llm_backend, model) no longer on the allowed-models catalog
402Org’s monthly LLM spend cap already met/exceeded, or org not entitled
404Unknown job, or belongs to a different org
409Job isn’t failed, or was triggered by the GitHub webhook — push a new commit (the PR’s synchronize event) to re-trigger analysis for those instead
422Job predates retry support (no stored llm_backend/model), or nothing was ingested before it failed

Stops a queued or running job so it stops spending the org’s LLM budget. Available on every trigger — a webhook-triggered job costs exactly what a dashboard one does.

A cancelled job is terminal and is not retryable through POST /jobs/{job_id}/retry (which only accepts failed). Nothing is lost, though: the namespace keeps every resource analyzed before the stop, so re-submitting the same .../analyze request picks up exactly where this left off and re-analyzes nothing — the same resume behavior a retry would have given you.

Response 200

{ "job_id": "b7e2...", "status": "cancelled", "was": "running" }

was is the status the job was in when it was cancelled: queued means it never reached the LLM at all, running means it stops after its current batch settles.

StatusDescription
200Cancelled
404Unknown job, or belongs to a different org
409Job already reached a terminal state (done, failed, or cancelled)

Structured, individually addressable security rules an org layers onto one or more analyzers’ built-in rulesets — replaces an earlier single free-text “rule pack” per (org, analyzer). Every enabled rule targeting an analyzer is appended after that analyzer’s built-in rules whenever GET /{analyzer}/rules is called (or resolved inline via get_next_resource), grouped by category. New orgs get every registered analyzer’s built-in rules imported automatically as enabled rows (is_builtin_seed: true) at setup — POST /orgs/{org_id}/rules/seed-builtin is the idempotent manual backdoor for orgs that predate this or after a new analyzer is registered.

Requires admin role. category must be a category_id this org owns (see Rule categories) — validated at write time.

Request body

{
"title": "Missing cost-center tag",
"description": "Flag any resource missing a cost-center tag",
"category": "cost_scale",
"severity": "MEDIUM",
"analyzers": ["terraform-plan", "terraform-code-change"],
"rule_type": null,
"enabled": true,
"requires_review": false
}
FieldTypeDefaultDescription
titlestring1–200 chars
descriptionstringMarkdown detection guidance fed to the LLM
categorystringA category_id this org owns
severitystringCRITICAL / HIGH / MEDIUM / LOW / INFO
analyzersstring[]≥ 1 analyzer name this rule applies to
rule_typestring | nullnullScopes to one analyzer variant (e.g. aws); null applies to every variant
enabledbooltrue
requires_reviewboolfalseA match always starts as needs_review instead of open, regardless of LLM confidence — for judgment-call rules (e.g. “is this the right module for this use case”) rather than a deterministic pass/fail

Response 201

{
"rule_id": "missing-cost-center-tag",
"org_id": "8f2a...",
"title": "Missing cost-center tag",
"description": "Flag any resource missing a cost-center tag",
"category": "cost_scale",
"severity": "MEDIUM",
"analyzers": ["terraform-plan", "terraform-code-change"],
"rule_type": null,
"enabled": true,
"requires_review": false,
"is_builtin_seed": false,
"created_by": "u1...",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": null
}
StatusDescription
201Created
404An analyzers entry isn’t a registered analyzer name
422category doesn’t resolve to a category this org owns

Any member. Filter by analyzer, category, rule type, and/or enabled state.

ParameterTypeDescription
analyzerstringOnly rules targeting this analyzer
categorystringExact category_id
rule_typestringExact rule variant
enabledboolFilter by enabled state

Response 200{"rules": [...]}, each shaped like the create response above.


Any member. 404 if not found.


Requires admin role. Partial update — only fields present in the body change. rule_id itself never changes, even if title does.

StatusDescription
200Updated — returns the full rule, same shape as create
404Rule not found
422An updated analyzers entry isn’t registered, or category doesn’t resolve

Requires admin role.

StatusDescription
200Deleted
404Rule not found

Requires admin role. (Re-)imports every registered analyzer’s built-in rules as enabled, editable rows. Idempotent — skips any rule already seeded for this org+analyzer+variant (matched by title, not the built-in file’s own id), so it’s safe to call again after a new analyzer or rule variant is registered.

Response 200

{"created": 24}

The grouping custom rules are organized under — an org-owned list, not a fixed enum. Every category is fully editable and deletable, including the 9 seeded defaults; there’s no protected/system category. Deleting a category still referenced by a rule is not blocked — GET /{analyzer}/rules groups any rule whose category no longer resolves under a fixed “Uncategorized” heading rather than erroring. New orgs get the 9 defaults seeded automatically, always before custom rules are seeded (built-in rules reference these same category ids) — POST /orgs/{org_id}/rule-categories/seed-defaults is the idempotent manual backdoor for orgs that predate this.

Requires admin role. category_id is derived from name and is immutable afterwards.

Request body

{"name": "Cost & Scale"}

Response 201

{
"category_id": "cost-scale",
"org_id": "8f2a...",
"name": "Cost & Scale",
"is_builtin_seed": false,
"created_by": "u1...",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": null
}

Any member. Lists every category for this org.

Response 200{"categories": [...]}, each shaped like the create response above.


GET /orgs/{org_id}/rule-categories/{category_id}

Section titled “GET /orgs/{org_id}/rule-categories/{category_id}”

Any member. 404 if not found.


PATCH /orgs/{org_id}/rule-categories/{category_id}

Section titled “PATCH /orgs/{org_id}/rule-categories/{category_id}”

Requires admin role. Renames namecategory_id itself never changes.

Request body

{"name": "Cost, Scale & Capacity"}
StatusDescription
200Renamed — returns the full category
404Category not found

DELETE /orgs/{org_id}/rule-categories/{category_id}

Section titled “DELETE /orgs/{org_id}/rule-categories/{category_id}”

Requires admin role. Not blocked by in-use rules — see the section intro above.

StatusDescription
200Deleted
404Category not found

POST /orgs/{org_id}/rule-categories/seed-defaults

Section titled “POST /orgs/{org_id}/rule-categories/seed-defaults”

Requires admin role. (Re-)imports the 9 default categories. Idempotent — skips any category_id already present for this org.

Response 200

{"created": 9}