GitHub Action (Mistral Vibe)
Mistral Vibe is a tool-calling
agent framework; action-mistral-vibe is a composite GitHub Action that
runs one inside your workflow. Point it at OpenTremor’s MCP endpoint and it
handles the whole loop itself — ingest the plan, work through each
resource, and leave a security report as a PR comment — with no glue code
in your workflow beyond the plan-generation step.
Don’t need CI at all? The GitHub App integration analyses every PR automatically the moment it’s installed — no workflow file, and a free choice between
anthropic,openai, ormistralas the backend. Reach for this Action instead when you need CI-side control: runningterraform planyourself first (as below), tuning the agent’s prompt directly, or slotting this into an existing Mistral Vibe-based CI setup. Nothing about the pattern here is Mistral-specific, either — any CI action that wraps a tool-calling model can drive the same MCP endpoints the same way; this is just the one with a ready-made Action already built.
The moving parts
Section titled “The moving parts”sequenceDiagram
participant CI as GitHub Actions
participant Action as action-mistral-vibe
participant Vibe as Mistral Vibe (Devstral)
participant API as OpenTremor Core
CI->>Action: prompt + plan text + MCP server config
Action->>Vibe: launch agent, MCP tools attached
Vibe->>API: ingest(namespace, plan_text)
API-->>Vibe: analysis units[]
loop until HTTP 208
Vibe->>API: get_next_resource(namespace)
API-->>Vibe: unit body + inline rules
Note over Vibe: evaluate body against rules
Vibe->>API: submit_analysis(hash, findings)
end
Vibe->>API: generate_report(namespace)
API-->>Vibe: consolidated report
Action->>CI: comment the report on the PR
Before you start
Section titled “Before you start”- An OpenTremor Core deployment your CI runner can reach (see Deployment)
- A Mistral API key with tool-use access (
devstral-2or similar) action-mistral-vibeavailable to your organisation
A working example
Section titled “A working example”name: Terraform security analysis
on: pull_request: paths: - "**.tf" - "**.tfvars"
permissions: id-token: write # needed for OIDC -> AWS role assumption contents: read pull-requests: write # so the action can leave a PR comment
jobs: terraform-security: runs-on: prl-self-hosted
steps: - name: Checkout uses: actions/checkout@v5
- name: Assume the plan/read role via OIDC uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: ${{ vars.AWS_OIDC_ROLE_ARN }} aws-region: ${{ vars.AWS_REGION }}
- name: Setup Terraform uses: hashicorp/setup-terraform@v3
- name: Render the plan as text id: plan run: | terraform init -input=false terraform plan -out=tfplan -input=false terraform show -no-color tfplan > plan.txt echo "stdout<<EOF" >> $GITHUB_OUTPUT cat plan.txt >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT working-directory: ${{ vars.TF_WORKING_DIR }} # e.g. terraform/envs/staging/
- name: Run the analysis agent id: analyse uses: your-org/action-mistral-vibe@main with: prompt: | You have access to the OpenTremor MCP server ("terraform-analyzer"). Use it to review the Terraform plan below for security issues. Work through these steps in order and don't skip any of them:
1. Call ingest(analyzer_name="terraform-plan", namespace="pr-${{ github.run_id }}", raw_input=<the plan text pasted below>). This returns a JSON array of units. If it's empty, stop and report that nothing was found to analyse.
2. Loop: a. Call get_next_resource(namespace="pr-${{ github.run_id }}"). - A 208 status means every unit has been analysed — move on to step 3. - Otherwise the response carries both the unit's body and the rules to check it against. b. Evaluate the body against those rules. c. Call submit_analysis(hash=<the unit's hash>, <your findings>). d. Go back to 2a.
3. Call generate_report(namespace="pr-${{ github.run_id }}", format="markdown_light") and return its output verbatim as your final message — nothing added before or after it. It already contains a link to the full HTML report, which needs no authentication to open.
Plan: ${{ steps.plan.outputs.stdout }} config_toml: | active_model = "devstral-2"
[[providers]] name = "mistral" api_base = "https://codestral.mistral.ai/v1" api_key_env_var = "ENDPOINT_API_KEY" api_style = "openai" backend = "mistral"
[[models]] name = "mistral-vibe-cli-latest" provider = "mistral" alias = "devstral-2" temperature = 0.2 input_price = 0.4 output_price = 2.0
[[mcp_servers]] name = "terraform-analyzer" transport = "streamable-http" url = "${{ vars.MCP_BASE_URL }}/mcp/" headers = { "X-API-Key" = "${{ secrets.MCP_API_KEY }}" } post_raw_output: "true" comment_header: "## OpenTremor security analysis" max_price: "0.50" timeout: "300" env: ENDPOINT_API_KEY: ${{ secrets.MISTRAL_API_KEY }}Secrets and variables this workflow expects
Section titled “Secrets and variables this workflow expects”| Name | Kind | What it’s for |
|---|---|---|
MISTRAL_API_KEY | Secret | forwarded to the action as ENDPOINT_API_KEY |
MCP_API_KEY | Secret | OpenTremor API key |
AWS_OIDC_ROLE_ARN | Variable | role the OIDC step assumes for Terraform state access |
AWS_REGION | Variable | e.g. eu-west-1 |
MCP_BASE_URL | Variable | where your OpenTremor deployment lives, e.g. https://analyzer.example.com |
TF_WORKING_DIR | Variable | Terraform root to plan, e.g. terraform/envs/staging/ |
Action inputs, at a glance
Section titled “Action inputs, at a glance”| Input | What it controls | Example |
|---|---|---|
prompt | the instructions driving the whole run, plan text included inline | see above |
config_toml | Vibe’s own config — MCP server URL, auth header, model | see above |
post_pr_comment | whether the final report gets posted as a PR comment | "true" |
comment_header | heading text on that comment | "## OpenTremor security analysis" |
max_price | dollar ceiling before the run is aborted | "0.50" |
timeout | seconds before the agent is killed | "300" |
publish_report | also push an HTML copy to GitHub Pages | "true" |
always_run | rerun even if this SHA already has a passing result | "false" |
What’s in config_toml
Section titled “What’s in config_toml”Whatever you put in config_toml is dropped straight into Vibe’s own
config.toml — the [[mcp_servers]] block is what tells it OpenTremor
exists and how to reach it:
[[mcp_servers]]name = "terraform-analyzer" # namespace the LLM sees these tools undertransport = "streamable-http"url = "https://analyzer.example.com/mcp/"headers = { "X-API-Key" = "your-user-key" } # drop this line if auth is offVibe introspects the server at startup and picks up every tool it exposes —
get_rules, ingest, get_next_resource, submit_analysis,
generate_report, and so on — there’s no separate tool list to maintain.
Running plan and code-change analysis together
Section titled “Running plan and code-change analysis together”Ingesting both a plan and a diff into the same namespace lets one agent run
cover both — each unit’s metadata.analyzer tells it which ruleset applies,
so the loop itself doesn’t change at all:
- name: Render plan and diff run: | terraform init -input=false terraform plan -out=tfplan -input=false terraform show -no-color tfplan > /tmp/plan.txt git diff origin/main -- '*.tf' > /tmp/diff.txt
- name: Run the analysis agent uses: your-org/action-mistral-vibe@main with: prompt: | Using the terraform-analyzer MCP server, run both a plan analysis and a code-change analysis in this same session.
1. Ingest both inputs into one namespace: a. ingest(analyzer_name="terraform-plan", namespace="pr-${{ github.run_id }}", raw_input=<contents of /tmp/plan.txt>) b. ingest(analyzer_name="terraform-code-change", namespace="pr-${{ github.run_id }}", raw_input=<contents of /tmp/diff.txt>)
2. Drain the queue: a. get_next_resource(namespace="pr-${{ github.run_id }}") - 208 means everything's analysed — go to step 3. - otherwise you get the unit's body plus the rules to check it with. b. Evaluate the body against those rules. c. submit_analysis(hash=<the unit's hash>, <your findings>) d. Repeat from 2a.
3. generate_report(namespace="pr-${{ github.run_id }}") and return its output verbatim as your final message. config_toml: | ...One report comes out the other end covering the plan’s security findings alongside the diff’s compliance findings.
Terramate output works unmodified
Section titled “Terramate output works unmodified”A concatenated multi-stack terramate run terraform show -no-color tfplan
run doesn’t need any special handling — hand it to ingest exactly as
produced. The parser recognises each terramate: Entering stack in <path>
marker and tags every unit with the stack it came from, and the rendered
report picks up a Stack column automatically once at least one unit
carries that tag — so a finding can always be traced back to the stack that
introduced it.
# All stacks, one fileterramate run terraform show -no-color tfplan > /tmp/full_plan.txtFeed that file’s contents into the prompt the same way as a single-stack plan (see Large plans below if it’s too big to inline).
Large plans
Section titled “Large plans”If a plan is too big for the action’s prompt character limit, write it to
a file and have the agent read from there instead:
- name: Save plan to file run: terraform show -no-color tfplan > /tmp/plan.txt
- name: Analyse uses: your-org/action-mistral-vibe@main with: prompt: | Read /tmp/plan.txt and analyse it for security issues using the terraform-analyzer MCP tools: ingest it, then loop get_next_resource + submit_analysis until done, then generate_report. config_toml: | [[mcp_servers]] name = "terraform-analyzer" transport = "streamable-http" url = "${{ vars.MCP_BASE_URL }}/mcp/"Keeping cost predictable
Section titled “Keeping cost predictable”max_price aborts the run once estimated spend crosses the threshold you
set. A typical 10-30 resource plan against devstral-2 runs somewhere
around $0.05-$0.20.
max_price: "0.30" # comfortable ceiling for most PRsFull token and cost accounting is available afterward via
${{ steps.<id>.outputs.result }}.
Not re-running on an unchanged commit
Section titled “Not re-running on an unchanged commit”The action remembers a successful run per commit SHA by default — pushing
again to the same SHA posts a “skipped, already analysed” comment instead
of re-running the (paid) LLM loop. Pass always_run: "true" if you want it
to run anyway every time.
What the action outputs
Section titled “What the action outputs”| Output | Contents |
|---|---|
status | success or failure |
result | full JSON — findings, token counts, cost |
execution_time | how long the run took, in seconds |
error_message | why it failed, if it did |
Wire these into later steps as needed:
- name: Fail loudly if the analysis errored if: steps.analyse.outputs.status == 'failure' run: echo "Analysis failed: ${{ steps.analyse.outputs.error_message }}"