Endpoints: Analyzers & Rules
Analyzers
Section titled “Analyzers”GET /analyzers
Section titled “GET /analyzers”List all registered analyzer backends.
Response 200
{ "analyzers": [ {"name": "terraform-plan", "description": "Analyses terraform plan output…"} ]}GET /orgs/{org_id}/analyzers
Section titled “GET /orgs/{org_id}/analyzers”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}| Status | Description |
|---|---|
200 | Override saved |
404 | analyzer_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}.
| Status | Description |
|---|---|
200 | Analyzer disabled |
404 | analyzer_name isn’t a registered analyzer |
GET /{analyzer_name}/rules
Section titled “GET /{analyzer_name}/rules”Return the Markdown security ruleset for an analyzer.
| Parameter | Description |
|---|---|
analyzer_name | Registered analyzer name (e.g. terraform-plan) |
| Parameter | Type | Description |
|---|---|---|
type | string (optional) | Sub-category filter (e.g. aws, gcp) |
| Status | Description |
|---|---|
200 | Markdown text (text/markdown) |
404 | Analyzer not found, or no rule file for the requested type |
501 | Analyzer does not support rules yet |
curl http://localhost:8000/terraform-plan/rulescurl http://localhost:8000/terraform-plan/rules?type=awsPOST /{analyzer_name}/{namespace}/ingest
Section titled “POST /{analyzer_name}/{namespace}/ingest”Parse raw input and split it into analysis units.
| Parameter | Description |
|---|---|
analyzer_name | Registered analyzer name |
namespace | Logical 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"} }]| Field | Type | Description |
|---|---|---|
hash | string | Stable content hash — use this key for analysis submission |
type | string | Terraform resource type |
name | string | Resource name from the plan |
action | string | create / update / destroy / no-op |
body | string | Full resource block (content to analyse) |
metadata | object | Parser-level metadata |
| Status | Description |
|---|---|
201 | Ingested — returns unit list |
404 | Unknown analyzer |
413 | Input > 10 MB |
422 | Empty body |
501 | Analyzer does not support ingestion |
POST /{analyzer_name}/{namespace}/analyze
Section titled “POST /{analyzer_name}/{namespace}/analyze”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.
| Parameter | Description |
|---|---|
analyzer_name | Registered analyzer name |
namespace | Logical 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"}| Field | Type | Description |
|---|---|---|
llm_backend | string | anthropic, mistral, or openai |
model | string | Provider-specific model name |
api_key | string | null | LLM provider API key. Omit to fall back to a credential stored via POST /orgs/{org_id}/llm-credentials |
raw_input | string | Same raw text you’d send to .../ingest |
rule_type | string | null | Optional 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.
| Status | Description |
|---|---|
200 | Analysis complete (sync) — full report |
202 | Job queued (async) — poll for status |
400 | No api_key given and none stored for this org/provider, or (llm_backend, model) isn’t on the allowed-models catalog |
402 | Org’s monthly LLM spend cap already met/exceeded — checked before any work starts, see Billing / quota |
404 | Unknown analyzer, or analyzer disabled for this org |
413 | Input exceeds server.max_input_size_mb |
501 | Analyzer does not support ingestion |
502 | LLM provider returned output that couldn’t be parsed (sync path only) |
POST /{namespace}/ingest/auto
Section titled “POST /{namespace}/ingest/auto”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).
| Parameter | Description |
|---|---|
namespace | Logical 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} ]}| Field | Type | Description |
|---|---|---|
units | array | Same shape as .../ingest’s response, merged across every matched analyzer |
analyzers_used | string[] | Analyzers actually invoked |
detections | array | One 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 |
| Status | Description |
|---|---|
201 | At least one file/blob matched an analyzer |
413 | Input > 10 MB |
422 | Empty 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 |
POST /{namespace}/analyze/auto
Section titled “POST /{namespace}/analyze/auto”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.
| Parameter | Description |
|---|---|
namespace | Logical 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}]}| Status | Description |
|---|---|
200 | Analysis complete (sync) — full report |
202 | Job queued (async) — poll for status |
400 | No api_key given and none stored, or (llm_backend, model) not on the allowed-models catalog |
402 | Org’s monthly LLM spend cap already met/exceeded, or org not entitled |
422 | Empty body, or nothing matched any analyzer |
502 | LLM provider returned output that couldn’t be parsed (sync path only) |
GET /jobs
Section titled “GET /jobs”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.
| Parameter | Type | Default | Description |
|---|---|---|---|
status | string | — | queued, running, done, failed, or cancelled |
limit | int | 50 | Page size (1–200) |
offset | int | 0 | Pagination 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}GET /jobs/{job_id}
Section titled “GET /jobs/{job_id}”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).
| Status | Description |
|---|---|
200 | Job found |
404 | Unknown job, or belongs to a different org |
POST /jobs/{job_id}/retry
Section titled “POST /jobs/{job_id}/retry”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)
| Field | Type | Description |
|---|---|---|
api_key | string | Never 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..." }| Status | Description |
|---|---|
202 | Retry started — poll GET /jobs/{job_id} (the new one) as usual |
400 | No api_key given and none stored, or (llm_backend, model) no longer on the allowed-models catalog |
402 | Org’s monthly LLM spend cap already met/exceeded, or org not entitled |
404 | Unknown job, or belongs to a different org |
409 | Job 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 |
422 | Job predates retry support (no stored llm_backend/model), or nothing was ingested before it failed |
POST /jobs/{job_id}/cancel
Section titled “POST /jobs/{job_id}/cancel”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.
| Status | Description |
|---|---|
200 | Cancelled |
404 | Unknown job, or belongs to a different org |
409 | Job already reached a terminal state (done, failed, or cancelled) |
Custom rules
Section titled “Custom rules”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.
POST /orgs/{org_id}/rules
Section titled “POST /orgs/{org_id}/rules”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}| Field | Type | Default | Description |
|---|---|---|---|
title | string | — | 1–200 chars |
description | string | — | Markdown detection guidance fed to the LLM |
category | string | — | A category_id this org owns |
severity | string | — | CRITICAL / HIGH / MEDIUM / LOW / INFO |
analyzers | string[] | — | ≥ 1 analyzer name this rule applies to |
rule_type | string | null | null | Scopes to one analyzer variant (e.g. aws); null applies to every variant |
enabled | bool | true | — |
requires_review | bool | false | A 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}| Status | Description |
|---|---|
201 | Created |
404 | An analyzers entry isn’t a registered analyzer name |
422 | category doesn’t resolve to a category this org owns |
GET /orgs/{org_id}/rules
Section titled “GET /orgs/{org_id}/rules”Any member. Filter by analyzer, category, rule type, and/or enabled state.
| Parameter | Type | Description |
|---|---|---|
analyzer | string | Only rules targeting this analyzer |
category | string | Exact category_id |
rule_type | string | Exact rule variant |
enabled | bool | Filter by enabled state |
Response 200 — {"rules": [...]}, each shaped like the create response above.
GET /orgs/{org_id}/rules/{rule_id}
Section titled “GET /orgs/{org_id}/rules/{rule_id}”Any member. 404 if not found.
PATCH /orgs/{org_id}/rules/{rule_id}
Section titled “PATCH /orgs/{org_id}/rules/{rule_id}”Requires admin role. Partial update — only fields present in the body change. rule_id itself never changes, even if title does.
| Status | Description |
|---|---|
200 | Updated — returns the full rule, same shape as create |
404 | Rule not found |
422 | An updated analyzers entry isn’t registered, or category doesn’t resolve |
DELETE /orgs/{org_id}/rules/{rule_id}
Section titled “DELETE /orgs/{org_id}/rules/{rule_id}”Requires admin role.
| Status | Description |
|---|---|
200 | Deleted |
404 | Rule not found |
POST /orgs/{org_id}/rules/seed-builtin
Section titled “POST /orgs/{org_id}/rules/seed-builtin”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}Rule categories
Section titled “Rule categories”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.
POST /orgs/{org_id}/rule-categories
Section titled “POST /orgs/{org_id}/rule-categories”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}GET /orgs/{org_id}/rule-categories
Section titled “GET /orgs/{org_id}/rule-categories”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"}| Status | Description |
|---|---|
200 | Renamed — returns the full category |
404 | Category 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.
| Status | Description |
|---|---|
200 | Deleted |
404 | Category 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}