← Pushproof / API
Tokens

Drive Pushproof from your own code

The page reads your GitLab CI configuration in the browser for free — the .gitlab-ci.yml and any local include files pasted with it — with its line numbers, resolves anchors, <<: merge keys, !reference, extends and default: the way GitLab does, and runs 25 deterministic checks (GL-01..GL-25): jobs in stages that were never declared, needs: that name no job, rules: mixed with only:, script lines YAML reads as mappings, secrets written into the file, production deploys any branch can run, terraform apply -auto-approve without a gate, unpinned images, and more. Its two paid steps are available over HTTP. You send one JSON object with task set to "audit" or "rewrite", the pasted pipeline as config and the page’s check as a facts string, and the model replies with one JSON object: an audit with the corrected top-level block per finding and an answer for every flag, or the whole file rewritten with the fixes, with what changed, what was kept and the CI/CD variables to define.

Base URL: https://api.skillsafe.ai/v1/app-api. Every call sends Content-Type: application/json and, once you have a token, Authorization: Bearer <token>. The paths below (/guest, /me, /estimate, /run, /jobs/{job_id}, /run-stream) are relative to it, exactly as sdk.js calls them. A plain-text summary of the app for agents is at /llms.txt.

The response envelope

Responses are JSON of the form {"ok": ..., "data": ..., "error": {...}}. The page’s SDK (sdk.js) decides success from the HTTP status, not from ok: on a 2xx it returns data; otherwise it raises an error whose message is error.message (or the HTTP status text when the body carries none), with status, code (error.code) and details (error.details) attached. A body that is not JSON is treated as {}.

{ "ok": true,  "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "details": { ... } } }

Error codes

Branch on the HTTP status and error.code, show error.message. The page handles 401 and 402 by name; the rest reach it as a generic failure (“The run failed: ...”).

HTTPerror.codeWhat it means / what to do
400VALIDATION_ERRORThe body is not a JSON object or a declared field has the wrong type. Every field is a string; sending facts or audit_handoff as an object is the usual cause.
401UNAUTHORIZEDNo token, or one that expired or was revoked. The page forgets the signed-in user and says “Your session expired - sign in again.” Get a new token.
402INSUFFICIENT_CREDITSThe balance cannot cover the run. The page says “Not enough credits for this run.” with a top-up link, and tries never to submit into a 402: it disables the run button when the /me balance is below the estimate’s min_credits.
404NOT_FOUNDA wrong path, or a job id that does not belong to this token.
409CONFLICTAn Idempotency-Key replayed with a different body. Send the original body, or use a new key (the page bumps its :a<attempt> suffix for a retry).
429RATE_LIMITEDToo many requests. Back off with a delay; do not tight-loop, and never resend a metered run without its original Idempotency-Key.

Failures that do not arrive as an HTTP status:

WhatWhat happens
event: error on /run-streamThe SDK raises an error carrying the event’s message, code and job_id.
Job status: "failed"Polling stops; the job’s error field says why.
Polling past 180 sThe SDK’s own client-side limit (waitForJob, 1 s interval, 180 000 ms default): it throws “job timed out”. The job itself is not cancelled.

The task field: two lanes

task routes the run, and it takes exactly two values. Each lane has its own reply contract, and a reply never mixes them. The skill’s instructions tell the model to do the audit lane when task is missing or anything else (and the page’s own buildInput turns any lane that is not "rewrite" into "audit"), so a typo silently gets you an audit: always send one of the two values.

taskWhat it doesverdictUse it when
"audit"Reviews the pipeline: confirms or dismisses each page flag (GL-01..GL-24), adds what text rules cannot see, gives the verbatim evidence and the whole corrected top-level block for each finding, says in plain words when each job runs and what it does, and orders the fixes into a plan.unsafe | needs_work | readyBefore pushing: GitLab rejects the file, a job never shows up, or you want to know whether production and secrets are safe.
"rewrite"Rewrites the whole main file with the fixes applied (rules instead of only/except, pinned images, default: timeout, needs, artifact expiry, resource_group and a manual gate on production, secrets as CI/CD variables, interruptible tests); lists each change with the flags it fixes, the flags it keeps with a reason, removed jobs and the CI/CD variables to define.ready | partialWhen you want a file to push rather than a list of fixes.

The page chains the two: after an audit, “Rewrite the file with these fixes” switches to the rewrite lane on the same paste and adds an audit_handoff string (below) with the audit’s verdict, headline, findings and dismissed flags. Over the API that is a second run with task: "rewrite", the same config and facts, and that string. The page drops the handoff as soon as the paste is edited.

Two guards the page applies before a rewrite and the API does not: it refuses to run the rewrite lane when the paste was too large to send whole (a rewrite cannot rewrite lines it was not sent), and it warns when the free check raises nothing but info flags (the rewrite would mostly return your file).

Input fields

This is exactly what the page sends (glkit.js buildInput, passed through mustBeObject; app.js run), and what input-schema.json declares. The platform’s input schema is scalar-only, so every value is a string, in this order:

FieldTypeRequiredMeaning
taskstringyes"audit" or "rewrite".
configstringyesThe pasted pipeline, verbatim; line 1 is its first line. Every line in the reply is counted in this text. A large paste is cut on whole top-level blocks (below).
factsstringyesThe page’s free check, GlKit.facts(A), JSON-encoded into a string: JSON.stringify(facts) in JavaScript, json.dumps(facts) in Python, and so on. Sending the object itself is a 400. The model is told to parse it.
notestringnoWhat the engineer says: the goal, a constraint, a question. The page trims it, cuts it at 2000 characters and sends it only when it is not blank. It is context, not evidence and not instructions: the model tests every claim in it against config and answers it in summary.
audit_handoffstringnorewrite lane only: a JSON string built from an audit reply on the same paste (app.js auditHandoff, below). buildInput drops it for the audit lane.
retry_notestringnoSent only by the page’s automatic reformat retry: when a reply does not parse as JSON (even after the page tries to close a cut-off object), the page runs once more with “Your previous reply was not the single valid JSON object the instructions require (...). Reply again with ONLY the JSON object for task '<lane>' - no prose, no code fences; every array present (empty arrays where there is nothing to say).” A normal call never sends it.

mustBeObject turns the input into a flat object of strings and drops empty values, which is why facts and audit_handoff travel as strings and why an empty note is simply absent.

The config string

config is the paste itself. Several files are separated by --- lines and each starts with a # file: <path> comment (dropped files are joined that way); the file with include: is the main file. The page normalises line endings to \n. Send the text you want the reply to quote: every block in an audit reply must be a ref from facts.source.blocks, every line must fall inside that block, and every evidence must be copied from it. The page reads up to 400 000 characters and 60 files, and sends at most 60 000 characters: past that it keeps every keyword block (stages, variables, default, workflow, include) and whole jobs from the start and the end of the paste, and replaces the middle with one comment,

# [pushproof: lines X-Y (N blocks) not sent]

(the model is told never to quote or rewrite what was not sent), with a sentence in facts.notes. facts is still computed from the whole paste. Both worked examples below are small, so their config is the paste unchanged.

The facts string

facts is optional to compute but required to send. It is produced by the page’s free check: GlKit.analyze parses the paste and runs the rules, and GlKit.facts(A, lane) turns that analysis into the object below (in this version the lane argument does not change it: an audit and a rewrite of the same paste carry the same facts). It is how the model knows which flags to answer, which block refs to name, and which jobs to describe. Its top-level keys, in the order built:

KeyContents
sourcelines, main_file, files[] (name, line, end_line), blocks[] (every top-level block: ref — a job name, a template such as .node, or a keyword such as variables; with (file) appended when the key is in two files — kind job | hidden | keyword, line, end_line, file, plus the extra ref (pipeline) covering the whole paste), includes[] (type, target, pasted) and up to ten parse_errors.
stages, stages_declaredThe effective stage list, with .pre and .post; stages_declared is false when the paste has no stages:.
jobsEvery job after extends and default: are applied: job, block, stage, runs (its rules or only/except, summarised), gate (manual, restricted or every pipeline), and when present extends, needs, dependencies, environment, deploy, production, image, services, timeout, interruptible, resource_group, allow_failure, trigger, cache_keys, artifacts; always script_lines.
templates, workflow_rulesThe hidden templates by name; the workflow:rules summarised, or null.
flagsEvery flag the page raised: uid (F1, F2, ...), id (GL-01..GL-25), sev (high | medium | low | info), resource (a block ref), line, text and detail. The reply must answer every non-info flag by its uid.
verdict_candidateThe audit verdict the flags point at (the page’s own reading).
notesPlain sentences about what was not read or not sent.

The simplest source is glkit.js itself: the page loads it from /glkit.js, and it also runs in Node (it sets module.exports). This script writes the two files the samples read; for both worked examples it reproduces the page’s config and facts exactly:

// build-facts.js -- run: node build-facts.js .gitlab-ci.yml   (glkit.js saved next to it)
const fs = require("fs");
const GlKit = require("./glkit.js");
const A = GlKit.analyze(fs.readFileSync(process.argv[2], "utf8"));
if (!A.ok) { console.error(A.errors.join("\n")); process.exit(1); }
fs.writeFileSync("config.yml", GlKit.sentConfig(A).text);                  // what the page sends as config
fs.writeFileSync("facts.json", JSON.stringify(GlKit.facts(A), null, 2));  // what it sends, compacted, as facts
console.log(A.flags.length + " flags; verdict_candidate " + A.candidate);

Without the page’s check. The platform only checks that facts is a string, so sending "{}" works: the model still reads config. But you lose what the check adds: with no flags there is nothing to confirm, dismiss, fix or keep, with no source.blocks there are no refs to name blocks by, and with no jobs the audit has no list of jobs to describe. Build it with glkit.js as above when you can.

The audit_handoff string

Only for task: "rewrite", and only for the paste the audit was about. The page builds it from the normalised audit reply r (recon.js normalize, below):

JSON.stringify({ from: "audit", verdict: r.verdict, headline: r.headline,
  findings: r.findings.map(f => ({ id: f.id, block: f.block, severity: f.severity, title: f.title, flags: f.flags })),
  dismissed: r.flag_answers.filter(a => a.status === "dismissed").map(a => ({ flag: a.flag, reason: a.reason })) })

That is {"from": "audit", "verdict", "headline", "findings": [{id, block, severity, title, flags}], "dismissed": [{flag, reason}]}. The rewrite is then expected to change or keep every finding it lists, and to say in summary which of the audit’s fixes were applied as written and which were done differently. It is a previous reply, not evidence: the model checks it against config.

The output contracts

The model is instructed (SKILL.md) to reply with one JSON object and nothing else — no prose, no code fences — with every array of its lane present ([] when empty). The run’s output text is that object. In both lanes it must never write a secret value: a secret is a CI/CD variable referenced as $NAME, and a literal secret in the paste is quoted as its key with *** for the value. Every page flag whose sev is not info is answered exactly once — in the audit lane in flag_answers, in the rewrite lane in changes or kept — and an info flag is never reported as a finding.

task: "audit"

task: "rewrite"

How the page reads a reply (recon.js parseResult and normalize): it strips a code fence and parses from the first { to the last }. A reply is read as the lane that was asked for, unless it names the other lane and carries that lane’s body (a pipeline string for rewrite, a jobs array for audit). An unknown verdict shows as needs_work or partial and counts as a disagreement; an unknown severity becomes medium and counts as one too; ids like GL3 become GL-03; flags keeps only F<n> values; findings with no block, evidence and title are dropped; questions is capped at 5 and plan at 12. An audit reply carries 8 sections (verdict, headline, findings, flag_answers, jobs, plan, questions, summary) and a rewrite reply 9 (verdict, headline, pipeline, changes, kept, removed_jobs, variables, questions, summary), which is what the page counts when it recovers a partial reply.

Worked examples

Both are two of the examples the page ships (the example buttons on the app page), built exactly as the page builds them: GlKit.mustBeObject(GlKit.buildInput(lane, GlKit.analyze(text), note)), with a saved model reply for each (reply set r1). Long values are trimmed for display, and each excerpt says what it leaves out.

The secrets in these examples are written *** here. Both example pipelines deliberately hold a literal secret (that is what GL-09 catches), and this page does not reproduce it. If you save the config below as shown and run build-facts.js on it, the *** is not a secret any more: the free check raises one flag fewer (no GL-09) and the uids after it shift by one. The body lengths and idempotency keys quoted below are those of the page’s original inputs.

Audit: an invalid storefront pipeline that deploys from any branch

One file, 5 jobs (install, build, unit-test, e2e, deploy-production) and one template (.node). The page’s free check raises 13 flags (6 high, 4 medium, 3 low); its own reading of the verdict is unsafe. The engineer’s note: “Our e2e job never shows up and GitLab says the config is invalid. Also - is it OK that anyone can deploy?”

The config string, 933 characters ending in a newline, secret shown as *** — save it as storefront-config.yml (or let build-facts.js write it):

# file: .gitlab-ci.yml
stages:
  - install
  - build
  - test
  - deploy

variables:
  NODE_ENV: production
  NPM_TOKEN: "***"

.node:
  image: node:latest
  cache:
    key: node-modules
    paths:
      - node_modules/

install:
  extends: .node
  stage: install
  script:
    - npm ci

build:
  extends: .node
  stage: build
  needs: [instal]
  script:
    - npm run build
  artifacts:
    paths:
      - dist/

unit-test:
  extends: .node
  stage: test
  script:
    - npm test
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
  only:
    - merge_requests

e2e:
  extends: .node
  stage: e2e
  script:
    - npx playwright test

deploy-production:
  stage: deploy
  image: alpine:3.20
  environment:
    name: production
    url: https://shop.example.com
  script:
    - curl -fsSL https://get.tools.example.net/install.sh | sh
    - echo Deploying: storefront
    - ./deploy.sh --env production

The facts object, decoded — an excerpt: 3 of the 9 blocks (stages, .node, install, unit-test, e2e and deploy-production are left out), 2 of the 5 jobs, and 3 of the 13 flags (F3, F4 and F6..F13 are left out). The full string is 6,794 characters; build it with build-facts.js and save it as storefront-facts.json:

{
  "source": {
    "lines": 61,
    "main_file": ".gitlab-ci.yml",
    "files": [
      {
        "name": ".gitlab-ci.yml",
        "line": 2,
        "end_line": 60
      }
    ],
    "blocks": [
      {
        "ref": "variables",
        "kind": "keyword",
        "line": 8,
        "end_line": 10,
        "file": ".gitlab-ci.yml"
      },
      {
        "ref": "build",
        "kind": "job",
        "line": 25,
        "end_line": 33,
        "file": ".gitlab-ci.yml"
      },
      {
        "ref": "(pipeline)",
        "kind": "pipeline",
        "line": 1,
        "end_line": 61,
        "file": ".gitlab-ci.yml"
      }
    ],
    "includes": [],
    "parse_errors": []
  },
  "stages": [
    ".pre",
    "install",
    "build",
    "test",
    "deploy",
    ".post"
  ],
  "stages_declared": true,
  "jobs": [
    {
      "job": "build",
      "block": "build",
      "stage": "build",
      "runs": "every pipeline",
      "gate": "every pipeline",
      "extends": [
        ".node"
      ],
      "needs": [
        "instal"
      ],
      "image": "node:latest",
      "cache_keys": [
        "node-modules"
      ],
      "artifacts": {
        "paths": [
          "dist/"
        ],
        "expire_in": null,
        "reports": []
      },
      "script_lines": 1
    },
    {
      "job": "deploy-production",
      "block": "deploy-production",
      "stage": "deploy",
      "runs": "every pipeline",
      "gate": "every pipeline",
      "environment": "production",
      "deploy": true,
      "production": true,
      "image": "alpine:3.20",
      "script_lines": 2
    }
  ],
  "templates": [
    ".node"
  ],
  "workflow_rules": null,
  "flags": [
    {
      "uid": "F1",
      "id": "GL-09",
      "sev": "high",
      "resource": "variables",
      "line": 10,
      "text": "Variable NPM_TOKEN in variables holds a literal secret",
      "detail": "Anything in .gitlab-ci.yml is readable by everyone who can read the repository, forever, in its history. Move it to a masked, protected CI/CD variable (Settings > CI/CD > Variables) or a secrets: integration, reference it as $NAME, and rotate the value - it has already leaked."
    },
    {
      "uid": "F2",
      "id": "GL-02",
      "sev": "high",
      "resource": "build",
      "line": 28,
      "text": "Job build needs 'instal', which is not a job in the paste",
      "detail": "GitLab rejects the file ('needs job instal is not defined'). Fix the name, or mark it optional: true if the job only exists in some pipelines."
    },
    {
      "uid": "F5",
      "id": "GL-10",
      "sev": "high",
      "resource": "deploy-production",
      "line": 54,
      "text": "Job deploy-production deploys to production (production) in every pipeline - any branch or merge request can run it",
      "detail": "Nothing restricts it to the default branch or a tag and nothing asks for a person: a push to any branch deploys. Add rules: that limit it to $CI_DEFAULT_BRANCH (or tags) with when: manual, and protect the environment."
    }
  ],
  "verdict_candidate": "unsafe",
  "notes": []
}

The request body the page sends, trimmed for display: the full body is 8,671 characters of compact JSON, and the two long strings are cut here after their first characters, with the cut marked inside the string. The step 4 samples build the untrimmed body from the two saved files.

{
  "task": "audit",
  "config": "# file: .gitlab-ci.yml\nstages:\n  - install\n  - build\n  - test\n  - deploy\n\nvariables:\n  NODE_ENV: production\n  NPM_TOKEN: \"***\"\n\n.node:\n  imag ... [trimmed for display: 160 of 933 characters shown]",
  "facts": "{\"source\":{\"lines\":61,\"main_file\":\".gitlab-ci.yml\",\"files\":[{\"name\":\".gitlab-ci.yml\",\"line\":2,\"end_line\":60}],\"blocks\":[{\"ref\":\"stages\",\"kind\":\"keyword\",\"line\": ... [trimmed for display: 160 of 6,794 characters shown]",
  "note": "Our e2e job never shows up and GitLab says the config is invalid. Also - is it OK that anyone can deploy?"
}

The page sends it with Idempotency-Key: pushproof:audit:82a15574-21df:a1 (see step 5). The saved reply, an excerpt: 2 of the 13 findings (GL-02 and GL-09; the others are GL-05, GL-01, GL-08, GL-10, GL-15, GL-17, GL-16, GL-12, GL-22, GL-23 and GL-21), 2 of the 13 flag answers (all 13 are confirmed), 1 of the 5 jobs, 3 of the 13 plan steps and 1 of the 4 questions. Note how the GL-09 evidence quotes the key and writes ***:

{
  "task": "audit",
  "verdict": "unsafe",
  "headline": "The file is invalid GitLab CI as written (four separate rejection-causing errors, including the exact reason e2e never shows up) and, once parseable, lets any branch push deploy straight to production with no gate while a real npm token sits hardcoded in variables.",
  "findings": [
    {
      "id": "GL-02",
      "flags": [
        "F2"
      ],
      "block": "build",
      "line": 28,
      "severity": "high",
      "title": "build needs a job named 'instal', which does not exist",
      "evidence": "needs: [instal]",
      "why": "GitLab rejects the whole pipeline at parse time (\"build job: undefined need: instal\"), so nothing in this file runs at all - not just build. This is the root cause behind the engineer's report that e2e never shows up too: an invalid file shows no jobs, not just the broken one.",
      "fix": "build:\n  extends: .node\n  stage: build\n  needs: [install]\n  script:\n    - npm run build\n  artifacts:\n    paths:\n      - dist/"
    },
    {
      "id": "GL-09",
      "flags": [
        "F1"
      ],
      "block": "variables",
      "line": 10,
      "severity": "high",
      "title": "NPM_TOKEN is a real token hardcoded in variables",
      "evidence": "NPM_TOKEN: \"***\"",
      "why": "Anything in .gitlab-ci.yml is readable by everyone who can read the repository, forever, in its commit history - this token is exposed right now and stays exposed even after the line is deleted. It must be rotated (the leaked value is no longer safe to use) and the new value stored as a masked, protected CI/CD variable instead of a file literal.",
      "fix": "variables:\n  NODE_ENV: production"
    }
  ],
  "flag_answers": [
    {
      "flag": "F1",
      "status": "confirmed",
      "reason": "Line 10 holds a literal npm token value, not a $NAME reference - real secret, must be rotated and moved to a CI/CD variable."
    },
    {
      "flag": "F2",
      "status": "confirmed",
      "reason": "install (line 19) is the only job defined; build's needs: (line 28) names 'instal', which does not exist anywhere in the paste."
    }
  ],
  "jobs": [
    {
      "job": "install",
      "runs": "Runs in every branch and tag pipeline (no rules:/only:, so it also runs on the default branch; it does not run in merge-request pipelines). Uses node:latest via the .node template and runs npm ci, writing into the shared node-modules cache key. On its own this job is fine, but right now the whole file is rejected before install or anything else can start, because of the four high-severity parse errors elsewhere (build's needs: [instal], unit-test's rules:+only:, e2e's undeclared stage, deploy-production's unquoted script line)."
    }
  ],
  "plan": [
    "Fix build's needs: [instal] to needs: [install] (GL-02) - the file is rejected without this.",
    "Remove only: from unit-test and keep rules: only (GL-05) - GitLab rejects rules:+only: together.",
    "Add e2e to stages: (and keep the e2e job as-is) so its stage: e2e resolves (GL-01) - this is the fix for 'e2e never shows up'."
  ],
  "questions": [
    "NPM_TOKEN is declared in variables: but is never referenced as $NPM_TOKEN anywhere in this file - where is it actually consumed (a .npmrc for a private registry, a publish step not shown)? Not visible in the paste."
  ],
  "summary": "Both symptoms in the note are confirmed against the config: e2e never shows up because stage: e2e (line 47) is not in stages: (lines 2-6), and GitLab is right that the file is invalid - that same error plus three more (build's needs: [instal] typo, unit-test's rules:+only: conflict, and an unquoted 'echo Deploying: storefront' script line) each independently make GitLab reject the whole pipeline, so all four must be fixed before anything runs. And no, it is currently not OK that anyone can deploy: deploy-production has no rules:/only: at all, so it fires on every branch and tag push straight to production with no manual step and no resource_group. There is also a live npm token hardcoded in variables: that needs rotating and moving into a masked, protected CI/CD variable. The remaining medium/low findings (a floating node:latest tag, duplicate branch+MR pipelines, a curl|sh install, and missing timeouts/interruptible/artifact expiry) don't block the pipeline but are worth cleaning up right after."
}

Held to the page’s own reconciliation (recon.js reconcile, on this exact body and the full reply), this reply has 0 disagreements in 30 statements checked.

What the step 5 samples print for the full reply (the Python form; charged is whatever the run cost):

truncated: False charged: ...
unsafe - The file is invalid GitLab CI as written (four separate rejection-causing errors, including the exact reason e2e never shows up) and, once parseable, lets any branch push deploy straight to production with no gate while a real npm token sits hardcoded in variables.
GL-02 build 28 high
GL-05 unit-test 42 high
GL-01 e2e 47 high
GL-08 deploy-production 59 high
GL-09 variables 10 high
GL-10 deploy-production 54 high
GL-15 .node 13 medium
GL-17 (pipeline) 41 medium
GL-16 deploy-production 54 medium
GL-12 deploy-production 58 medium
GL-22 (pipeline) 1 low
GL-23 (pipeline) 1 low
GL-21 build 31 low
job install
job build
job unit-test
job e2e
job deploy-production

Had you pressed “Rewrite the file with these fixes” after this reply, the page would send the rewrite lane on the same paste (the same config, facts and note) with this audit_handoff, shown decoded and trimmed to 3 of its 13 findings; in the body it is the compact JSON.stringify of it. That body is 11,095 characters and its key would be pushproof:rewrite:5ea3f77c-2b57:a1:

{
  "from": "audit",
  "verdict": "unsafe",
  "headline": "The file is invalid GitLab CI as written (four separate rejection-causing errors, including the exact reason e2e never shows up) and, once parseable, lets any branch push deploy straight to production with no gate while a real npm token sits hardcoded in variables.",
  "findings": [
    {
      "id": "GL-02",
      "block": "build",
      "severity": "high",
      "title": "build needs a job named 'instal', which does not exist",
      "flags": [
        "F2"
      ]
    },
    {
      "id": "GL-05",
      "block": "unit-test",
      "severity": "high",
      "title": "unit-test mixes rules: with only:, which GitLab rejects",
      "flags": [
        "F3"
      ]
    },
    {
      "id": "GL-01",
      "block": "e2e",
      "severity": "high",
      "title": "e2e runs in stage 'e2e', which stages: never lists",
      "flags": [
        "F4"
      ]
    }
  ],
  "dismissed": []
}

Rewrite: a Terraform pipeline that auto-applies with a state password in the file

One file, 3 jobs (validate, plan, apply) with a top-level image: and before_script:. The free check raises 10 flags (2 high, 3 medium, 5 low). This example has no audit before it, so it carries no audit_handoff. The engineer’s note: “Please make this safe: apply should only happen after someone reads the plan, and the state password must not be in the repo.”

The config string, 725 characters ending in a newline, secret shown as *** — save it as terraform-config.yml:

# file: .gitlab-ci.yml
stages:
  - validate
  - plan
  - apply

variables:
  TF_ROOT: ${CI_PROJECT_DIR}/terraform
  TF_VERSION: "1.6.0"
  TF_HTTP_PASSWORD: "***"

image:
  name: hashicorp/terraform
  entrypoint: [""]

before_script:
  - cd ${TF_ROOT}
  - terraform --version

validate:
  stage: validate
  script:
    - terraform init -backend=false
    - terraform validate
    - terraform fmt -check

plan:
  stage: plan
  script:
    - terraform init
    - terraform plan -out=tfplan
  artifacts:
    paths:
      - ${TF_ROOT}/tfplan

apply:
  stage: apply
  script:
    - terraform init
    - terraform apply -auto-approve
  dependencies:
    - plan
  environment:
    name: production
  only:
    - main

The facts object, decoded — an excerpt: 3 of the 8 blocks, 1 of the 3 jobs and 3 of the 10 flags (F3, F4 and F6..F10: GL-15, GL-16, GL-22, GL-19 twice, GL-21 and GL-20 are left out). The full string is 5,043 characters; save it as terraform-facts.json:

{
  "source": {
    "lines": 47,
    "main_file": ".gitlab-ci.yml",
    "files": [
      {
        "name": ".gitlab-ci.yml",
        "line": 2,
        "end_line": 46
      }
    ],
    "blocks": [
      {
        "ref": "variables",
        "kind": "keyword",
        "line": 7,
        "end_line": 10,
        "file": ".gitlab-ci.yml"
      },
      {
        "ref": "apply",
        "kind": "job",
        "line": 36,
        "end_line": 46,
        "file": ".gitlab-ci.yml"
      },
      {
        "ref": "(pipeline)",
        "kind": "pipeline",
        "line": 1,
        "end_line": 47,
        "file": ".gitlab-ci.yml"
      }
    ],
    "includes": [],
    "parse_errors": []
  },
  "stages": [
    ".pre",
    "validate",
    "plan",
    "apply",
    ".post"
  ],
  "stages_declared": true,
  "jobs": [
    {
      "job": "apply",
      "block": "apply",
      "stage": "apply",
      "runs": "only [\"main\"]",
      "gate": "restricted",
      "dependencies": [
        "plan"
      ],
      "environment": "production",
      "deploy": true,
      "production": true,
      "image": "hashicorp/terraform (from global)",
      "script_lines": 4
    }
  ],
  "templates": [],
  "workflow_rules": null,
  "flags": [
    {
      "uid": "F1",
      "id": "GL-09",
      "sev": "high",
      "resource": "variables",
      "line": 10,
      "text": "Variable TF_HTTP_PASSWORD in variables holds a literal secret",
      "detail": "Anything in .gitlab-ci.yml is readable by everyone who can read the repository, forever, in its history. Move it to a masked, protected CI/CD variable (Settings > CI/CD > Variables) or a secrets: integration, reference it as $NAME, and rotate the value - it has already leaked."
    },
    {
      "uid": "F2",
      "id": "GL-11",
      "sev": "high",
      "resource": "apply",
      "line": 40,
      "text": "Job apply runs terraform apply -auto-approve with no manual gate",
      "detail": "Infrastructure changes apply as soon as the pipeline reaches this job, with no one reading the plan. Save the plan in a plan job (terraform plan -out=plan.tfplan, artifact), apply that file, and make the apply job when: manual."
    },
    {
      "uid": "F5",
      "id": "GL-10",
      "sev": "medium",
      "resource": "apply",
      "line": 45,
      "text": "Job apply deploys to production automatically on every matching push (only [\"main\"])",
      "detail": "The skill's rule is a manual gate for production: without when: manual every merge goes straight out. If continuous deployment is intended, say so and protect the environment instead."
    }
  ],
  "verdict_candidate": "unsafe",
  "notes": []
}

The request body, trimmed the same way (the full body is 6,548 characters):

{
  "task": "rewrite",
  "config": "# file: .gitlab-ci.yml\nstages:\n  - validate\n  - plan\n  - apply\n\nvariables:\n  TF_ROOT: ${CI_PROJECT_DIR}/terraform\n  TF_VERSION: \"1.6.0\"\n  TF_HTTP_PASSWORD: \"tf- ... [trimmed for display: 160 of 725 characters shown]",
  "facts": "{\"source\":{\"lines\":47,\"main_file\":\".gitlab-ci.yml\",\"files\":[{\"name\":\".gitlab-ci.yml\",\"line\":2,\"end_line\":46}],\"blocks\":[{\"ref\":\"stages\",\"kind\":\"keyword\",\"line\": ... [trimmed for display: 160 of 5,043 characters shown]",
  "note": "Please make this safe: apply should only happen after someone reads the plan, and the state password must not be in the repo."
}

The page sends it with Idempotency-Key: pushproof:rewrite:1be08f7c-1994:a1. The saved reply, an excerpt: 3 of the 10 changes (every flag F1..F10 is fixed by one change; kept and removed_jobs are empty), with pipeline shown separately below:

{
  "task": "rewrite",
  "verdict": "ready",
  "headline": "Pulled the state password out of the file, made apply consume the saved plan behind a manual gate on main, pinned the Terraform image, and cleaned up the deprecated-keyword/hygiene issues (default:, timeout, artifact expiry, resource_group, rules).",
  "pipeline": "(the file below, as one string)",
  "changes": [
    {
      "flags": [
        "F1"
      ],
      "id": "GL-09",
      "block": "variables",
      "what": "Deleted the line `TF_HTTP_PASSWORD: \"***\"` from variables: entirely and left a comment saying it must instead be created in Settings > CI/CD > Variables as a masked, protected variable named TF_HTTP_PASSWORD, which Terraform's http backend reads straight from the job environment.",
      "why": "Anything in .gitlab-ci.yml is readable by everyone who can read the repository, forever, in its history - the value has already leaked and must be rotated; it can never live in the file again."
    },
    {
      "flags": [
        "F2"
      ],
      "id": "GL-11",
      "block": "apply",
      "what": "Changed the script from `terraform apply -auto-approve` to `terraform apply -input=false \"${TF_ROOT}/tfplan\"`, so apply consumes the exact plan file the plan job saved instead of re-deciding and auto-approving on its own, and put the job behind the `rules: when: manual` gate added for GL-10/GL-20 below.",
      "why": "auto-approve with no gate applies to production the moment the pipeline reaches this job, with no one reading the plan - which is the exact risk the engineer's note called out."
    },
    {
      "flags": [
        "F5"
      ],
      "id": "GL-10",
      "block": "apply",
      "what": "Job now only runs under `rules: - if: $CI_COMMIT_BRANCH == \"main\"` with `when: manual`, so a production apply waits for someone to click play instead of running automatically on every push to main.",
      "why": "the previous job deployed to the production environment on every matching push with no gate at all - exactly what the engineer's note asked to fix (\"apply should only happen after someone reads the plan\")."
    }
  ],
  "kept": [],
  "removed_jobs": [],
  "variables": [
    {
      "name": "TF_HTTP_PASSWORD",
      "purpose": "Password for the Terraform remote-state backend, previously hardcoded in variables: as TF_HTTP_PASSWORD: \"***\". Rotate that value before creating this - the old one already leaked into the repository's history. Referenced as $TF_HTTP_PASSWORD (Terraform's http backend reads TF_HTTP_PASSWORD directly from the environment; no further reference in the file is required beyond the comment in variables:).",
      "masked": true,
      "protected": true
    }
  ],
  "questions": [
    "This paste is only .gitlab-ci.yml - I don't have your main.tf/backend config. I assumed TF_HTTP_PASSWORD is read by a `backend \"http\"` block (or GitLab-managed Terraform state) straight from the job environment, which is why the rewritten file no longer references it anywhere except in a comment. If your backend consumes it a different way (a wrapper script, a -backend-config= flag, etc.), tell me and I'll wire the CI/CD variable in explicitly instead of relying on GitLab's automatic variable injection."
  ],
  "summary": "Every non-info flag is fixed: TF_HTTP_PASSWORD's literal value is gone from the file (create it as a masked, protected project CI/CD variable and rotate it, since it already leaked into git history), and apply now runs `terraform apply` against the plan job's saved tfplan artifact behind a `rules: ... when: manual` gate scoped to main - exactly the two things the note asked for. I also pinned the previously-untagged hashicorp/terraform image to TF_VERSION, moved the deprecated top-level image/before_script under default: alongside a 30-minute default timeout, added expire_in to the plan artifact, gave apply a resource_group so two pipelines can't race a production apply, and replaced apply's only: with rules:. One open question: confirm how your Terraform backend actually consumes TF_HTTP_PASSWORD, since that config isn't in this paste."
}

The reply’s pipeline, decoded (52 lines) — what the step 6 samples write to rewritten.gitlab-ci.yml:

stages:
  - validate
  - plan
  - apply

variables:
  TF_ROOT: ${CI_PROJECT_DIR}/terraform
  TF_VERSION: "1.6.0"
  # TF_HTTP_PASSWORD is intentionally not declared here. Create it in
  # Settings > CI/CD > Variables (masked, protected) as $TF_HTTP_PASSWORD -
  # Terraform's http backend reads it straight from the job environment.
  # Rotate the old value: it was committed to this file's history.

default:
  image:
    name: hashicorp/terraform:${TF_VERSION}
    entrypoint: [""]
  before_script:
    - cd ${TF_ROOT}
    - terraform --version
  timeout: 30 minutes

validate:
  stage: validate
  script:
    - terraform init -backend=false
    - terraform validate
    - terraform fmt -check

plan:
  stage: plan
  script:
    - terraform init
    - terraform plan -out=tfplan
  artifacts:
    paths:
      - ${TF_ROOT}/tfplan
    expire_in: 1 week

apply:
  stage: apply
  script:
    - terraform init
    - terraform apply -input=false "${TF_ROOT}/tfplan"
  dependencies:
    - plan
  environment:
    name: production
  resource_group: production
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'
      when: manual

Held to the page’s reconciliation, this reply has 0 disagreements in 19 statements checked; the page notes, as information, that the apply job’s command changed as a change says.

What the step 6 samples print for the full reply (the Python form):

truncated: False charged: ...
ready - Pulled the state password out of the file, made apply consume the saved plan behind a manual gate on main, pinned the Terraform image, and cleaned up the deprecated-keyword/hygiene issues (default:, timeout, artifact expiry, resource_group, rules).
change F1 GL-09 variables
change F2 GL-11 apply
change F3 GL-15 image
change F4 GL-16 apply
change F5 GL-10 apply
change F6 GL-22 (pipeline)
change F7 GL-19 image
change F8 GL-19 before_script
change F9 GL-21 plan
change F10 GL-20 apply
variable TF_HTTP_PASSWORD masked=True protected=True

The page checks the reply against the config; the API does not

What comes back from the API is the model’s raw text in output.output. The page holds it to account in the browser before showing it (recon.js reconcile, with the facts, the config and the handoff it sent). These checks are client-side: a direct API caller gets none of them.

If you call the API directly, apply your own checks — the evidence against your config, every flag answered, each fix or the rewritten file run through GitLab’s CI Lint — before you push anything.

Step 1 — a tiny client helper

One function per language that does what sdk.js does for every call: JSON in, bearer token if you have one, data out on a 2xx, an error carrying status, error.code and error.message otherwise. The later steps assume this helper is in the same file (for Go, Java and C#, the later snippets go inside main / the top-level statements; Java’s makeInput from step 4 goes in the class).

BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="${SKILLSAFE_TOKEN:-YOUR_TOKEN}"   # step 2

# api METHOD PATH [extra curl args...] -- prints the whole envelope; pipe to jq
api() {
  method="$1"; path="$2"; shift 2
  curl -sS -X "$method" "$BASE$path" \
    -H "Content-Type: application/json" \
    -H "Authorization: Bearer $TOKEN" "$@"
}
# success: jq '.data'      failure: jq '.error | {code, message}'

Step 2 — a token

Two kinds work:

# personal token (needed for a run): copy it from /tokens.html, then
#   export SKILLSAFE_TOKEN=...        and skip the rest of this block
# guest token (enough for /me and /estimate): no Authorization header on this one call
TOKEN=$(curl -sS -X POST "$BASE/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"pushproof"}' | jq -r '.data.token')
echo "guest token: ${TOKEN:0:12}..."

Step 3 — GET /me

Who the token belongs to and what it can spend. sdk.js documents the shape as {subject_type, subject_id, credits, profile?}. The page treats the caller as signed in only when subject_type is "user", shows credits as the balance (1 credit = $0.0001, so it divides by 10 000 for the dollar figure), and compares it with the estimate in step 4 before it lets you run.

api GET /me | jq '.data // .error'

Step 4 — build the input, then POST /estimate

First a small makeInput that does what buildInput does: task, then config as the pipeline text, facts as a compact JSON string, the note when it is not blank, and — for the rewrite lane only — the audit_handoff string when you have one. The samples build the audit input of the storefront example from the two files build-facts.js wrote; step 6 reuses makeInput for the rewrite example. Then price it. The body of /estimate is the same input you will send to /run; no job is created and nothing is charged — /estimate is free. From the response the page reads:

FieldMeaning
hold_creditsA reservation, not a price: the worst case the platform holds while the run is in flight. The page shows it as “reserves N credits ($...) for the Audit run” and adds that you are charged only for what the run actually uses. What you pay is the job’s charged_credits after settlement (step 5), normally less. A balance below the hold is allowed but the reply may be cut short (truncated: true).
min_creditsThe smallest balance that can start a run. Below it the page disables the run button, because the call would be a 402.
sponsor_enabledWhen true, a caller who is not signed in may run; otherwise the page asks them to sign in.
warningsInput warnings. They do not refuse the call; the page shows them next to the price, so check them before you run.

The page re-prices whenever the paste, the note or the lane change. The amounts grow with the size of config and facts, so read them from the call rather than hard-coding them.

# storefront-config.yml and storefront-facts.json: written by build-facts.js (see "The facts string").
# --rawfile keeps the YAML byte for byte (jq 1.6+); jq -c turns the facts into ONE compact string.
BODY=$(jq -n --rawfile config storefront-config.yml --arg facts "$(jq -c . storefront-facts.json)" \
  --arg note "Our e2e job never shows up and GitLab says the config is invalid. Also - is it OK that anyone can deploy?" \
  '{task: "audit", config: $config, facts: $facts, note: $note}')
# No page check at hand? --arg facts '{}' is accepted as a string, but the reply then has no page flags to answer.
# Rewrite lane after an audit of the SAME paste: task: "rewrite", plus
#   --arg handoff "$(jq -c . handoff.json)"  and  audit_handoff: $handoff  in the object.

api POST /estimate -d "$BODY" \
  | jq '.error // (.data | {hold_credits, min_credits, sponsor_enabled, warnings})'

Step 5 — POST /run, then poll the job (audit)

Send the same input to /run with an Idempotency-Key header, using a personal token. Its data carries a job_id. Poll GET /jobs/{job_id} the way sdk.js waitForJob does: every second until status is succeeded or failed, giving up on the client side after 180 seconds. On the finished job, read:

Idempotency key. Resending a request with the same key must not start a second paid run, so derive the key from the input, not from the clock — and include the lane, so an audit and a rewrite of the same paste never share a key. The page uses pushproof:<lane>:<inputHash>:a<attempt>, where <lane> is audit or rewrite, <inputHash> is a 32-bit djb2 hash of JSON.stringify(input) in hex, a dash, and the string’s length in hex (app.js inputHash, over the input without retry_note), and <attempt> is 1 for a normal run and 2 for the automatic reformat retry (which also adds retry_note). For the two worked examples that is pushproof:audit:82a15574-21df:a1 and pushproof:rewrite:1be08f7c-1994:a1. The format is the page’s convention, not a requirement: any stable, unique string does. The samples below use the lane and a SHA-256 prefix.

The page itself uses /run-stream (step 6), not /run; both take the same body and header.

KEY="pushproof:audit:$(printf '%s' "$BODY" | shasum -a 256 | cut -c1-16):a1"

JOB=$(api POST /run -H "Idempotency-Key: $KEY" -d "$BODY" | jq -r '.data.job_id')

for i in $(seq 180); do                       # 1 s interval, 180 s client-side limit
  J=$(api GET "/jobs/$JOB")
  S=$(printf '%s' "$J" | jq -r '.data.status')
  if [ "$S" = "succeeded" ] || [ "$S" = "failed" ]; then break; fi
  sleep 1
done

printf '%s' "$J" | jq '.data | {status, truncated, charged_credits, error}'
printf '%s' "$J" | jq -r '.data.output.output' \
  | jq '{verdict, headline, findings: [(.findings // [])[] | {id, block, line, severity}], jobs: [(.jobs // [])[] | .job]}'

Step 6 — POST /run-stream (Server-Sent Events, rewrite)

The alternative to step 5, and what the page uses: same kind of body, same Idempotency-Key header, one request. Run one or the other for a given input, not both. To show the second lane, the samples build the rewrite input of the Terraform example (with its own key) and stream it; to stream the audit input instead, pass the step 4 input and key. The response is text/event-stream. sdk.js splits it into frames on a blank line, takes the event name from an event: line (default message) and concatenates the data: lines, skips frames with no data or with data that is not JSON, and handles these events:

EventDataWhat the SDK does
delta{text}Passes text to onDelta.
jobjob infoPasses it to onJob (the page uses it to advance its progress display).
donethe final payload: {job_id, status, charged_credits, output}Keeps it as the result.
pendinga payloadTreated exactly like done. If it carries a job_id and no output, poll that job as in step 5.
error{message, code, job_id}After the stream ends, raises an error with those fields.

If the response is not text/event-stream, it is an ordinary JSON envelope: the SDK returns its data (an idempotent replay returns this way) or raises the error on a non-2xx status. Handle both shapes.

From the final payload the page reads output.output (falling back to the concatenated delta text), truncated and charged_credits. Browsers receive progress ticks rather than deltas from this endpoint, so the page relies on the final payload; if the stream fails part-way, it tries to recover a partial reply from whatever delta text arrived. The samples print the verdict and headline, each change’s flags, id and block, each kept flag, each variable to define, and write pipeline to rewritten.gitlab-ci.yml — paste that back into the app page to run the free check on it, which is what the page’s “Check this file” does. To rewrite a paste you audited, pass the handoff string as makeInput’s last argument.

# the rewrite worked example: terraform-config.yml and terraform-facts.json from build-facts.js
RBODY=$(jq -n --rawfile config terraform-config.yml --arg facts "$(jq -c . terraform-facts.json)" \
  --arg note "Please make this safe: apply should only happen after someone reads the plan, and the state password must not be in the repo." \
  '{task: "rewrite", config: $config, facts: $facts, note: $note}')
RKEY="pushproof:rewrite:$(printf '%s' "$RBODY" | shasum -a 256 | cut -c1-16):a1"

# -N turns off buffering so frames print as they arrive; tee keeps them for the next lines
api POST /run-stream -N -H "Idempotency-Key: $RKEY" -d "$RBODY" | tee stream.txt

# frames look like:
#   event: job
#   data: {...}
#
#   event: done
#   data: {"job_id":"...","status":"...","charged_credits":...,"output":{"output":"{\"task\":\"rewrite\",...}"}}

# the final reply text, from the done (or pending) frame:
REPLY=$(awk '/^event:/{ev=$2} /^data:/ && (ev=="done" || ev=="pending"){sub(/^data: */,""); print}' stream.txt \
  | jq -r '.output.output')
# an idempotent replay answers with a plain JSON envelope instead: REPLY=$(jq -r '.data.output.output' stream.txt)
printf '%s' "$REPLY" | jq '{verdict, headline, changes: [(.changes // [])[] | {flags, id, block}], kept, variables: [(.variables // [])[] | {name, masked, protected}]}'
printf '%s' "$REPLY" | jq -r '.pipeline' > rewritten.gitlab-ci.yml

What the page does that you now do

Or skip all of this and use Pushproof in the browser: the check, the flags, the jobs table, putting a fix back into the paste, and the Markdown and CSV exports are free.