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 body — text/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 body — application/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)

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

An inline run is recorded as a job too (mode: "sync"), so job_id resolves through GET /jobs/{job_id} exactly like a background one — and appears on the dashboard’s Jobs page alongside them. The error responses below carry job_id as well, which is the case that most needs it: a 502 used to leave nothing behind at all, so a small analysis that failed had no status to inspect and no way to resume the resources it had already ingested. An inline job cannot be cancelled (see POST /jobs/{job_id}/cancel); it can be resumed like any other.

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, or stopped at analysis.llm_max_output_tokens before finishing (sync path only — the async path records the same failure on the job instead). Response body carries job_id

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 body — text/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).

{"job_id": "b7e2...", "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, or stopped at analysis.llm_max_output_tokens before finishing (sync path only — the async path records the same failure on the job instead). Response body carries job_id

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
statusstring—queued, 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 queued → running → done | 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 job that stopped with work left to do, under a new job_id — the original 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.

Three statuses qualify, because all three describe the same situation from the namespace’s point of view — some resources analyzed, some not:

StatusWhy it’s resumable
failedStopped on an error, partway through
cancelledStopped because someone asked it to
done with quota_exceeded: trueFinished on the org’s monthly budget, with a partial report

A plain done job is refused with 409: there is nothing left for it to analyze, and resuming it would create a job and bill an entitlement check to process zero resources. A resumed job is always a background job (mode: "async"), whatever the run it resumes was.

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 has nothing left to analyze (a plain done, or one still queued/running), 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 stopped

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, but not a dead end: POST /jobs/{job_id}/retry resumes it on the same terms as a failed one, re-analyzing (and re-billing) nothing the original run already paid for. Re-submitting the same .../analyze request works too and does the same thing — but it needs the original raw_input, which is never persisted, so resuming is usually the only option actually available to 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), or is a mode: "sync" job with no background run to stop

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
titlestring—1–200 chars
descriptionstring—Markdown detection guidance fed to the LLM
categorystring—A category_id this org owns
severitystring—CRITICAL / 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 name — category_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}