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: ...”).
| HTTP | error.code | What it means / what to do |
|---|---|---|
400 | VALIDATION_ERROR | The 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. |
401 | UNAUTHORIZED | No 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. |
402 | INSUFFICIENT_CREDITS | The 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. |
404 | NOT_FOUND | A wrong path, or a job id that does not belong to this token. |
409 | CONFLICT | An 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). |
429 | RATE_LIMITED | Too 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:
| What | What happens |
|---|---|
event: error on /run-stream | The 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 s | The 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.
task | What it does | verdict | Use 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 | ready | Before 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 | partial | When 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:
| Field | Type | Required | Meaning |
|---|---|---|---|
task | string | yes | "audit" or "rewrite". |
config | string | yes | The 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). |
facts | string | yes | The 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. |
note | string | no | What 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_handoff | string | no | rewrite 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_note | string | no | Sent 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:
| Key | Contents |
|---|---|
source | lines, 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_declared | The effective stage list, with .pre and .post; stages_declared is false when the paste has no stages:. |
jobs | Every 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_rules | The hidden templates by name; the workflow:rules summarised, or null. |
flags | Every 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_candidate | The audit verdict the flags point at (the page’s own reading). |
notes | Plain 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:"audit".verdict:unsafe(any finding is high) |needs_work(the worst is medium or low) |ready(nothing above low).headline: one sentence.findings[]:{id, flags[], block, line, severity, title, evidence, why, fix}.idis a catalogue id (GL-01..GL-24) orother;flagslists the pageuids it confirms ([]for something the page did not flag);blockis areffromfacts.source.blocks, copied exactly — the block the fix changes;lineis a line ofconfiginside that block;severityishigh(GitLab rejects the file or a job, a secret is exposed, or anything reaches production or applies infrastructure without a gate) |medium|low;evidenceis one to three lines copied verbatim fromconfig;fixis YAML to paste over the block: the whole corrected top-level block (or several, or a new one such asworkflow:), spaces only, and""only for a low finding.flag_answers[]:{flag, status, reason}, exactly one per non-info page flag,statusconfirmed(a finding lists that uid) |dismissed(with the reason).jobs[]:{job, runs}, one per job infacts.jobs(not templates), named exactly as written;runssays in which pipelines it runs and what its script does, including the effect of any finding.plan[]: the fixes in the order to apply them — what makes GitLab accept the file first, then what protects production and secrets, then hygiene.questions[](only what the paste cannot answer) andsummary(two to four sentences, answeringnoteif one was sent).
task: "rewrite"
task:"rewrite".verdict:ready(nothing but info flags is kept and the file needs no answer toquestionsto be pushed) |partial.headline: one sentence.pipeline: the complete rewritten main file as one string — without the# file:comment or the other pasted files; valid YAML with spaces, no tabs, no literal secret. Every job keeps its name and every script command stays, in order, unless a change names that job.changes[]:{flags[], id, block, what, why}. Where an image is pinned,whysays the version is the model’s choice to confirm.kept[]:{flag, reason}for each flag not fixed (it needs a decision or a fact outside the paste).removed_jobs[]:{job, reason}.variables[]:{name, purpose, masked, protected}, every CI/CD variable the engineer must define that the paste did not already use as$NAME; never a predefinedCI_*variable.questions[]andsummary, as in the audit lane.
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.
- Audit: every finding is anchored. Its
idis in the catalogue (GL-25 is never a finding); itsseverityis high, medium or low; itsblockis a pasted block; itsevidenceis found in that block’s lines ofconfig(whitespace collapsed; quoted lines that are apart must appear in order; a***matches what comes before it); every citeduidis a non-info flag the page raised. - Audit: every fix is re-run. A fix must parse as YAML with no tabs and be a mapping of top-level blocks; a medium or high finding without a fix is a disagreement. The fix is put back into the file — replacing the block of the same name, or added — and the free check runs again: it must clear the flag it answers and introduce no new high flag.
- Audit: every flag is answered once, consistently, the verdict fits the severities, every job in
facts.jobsis described (and nothing else), and there is aplanwhenever there is something to fix. - Rewrite: the file is re-checked.
pipeline(with the other pasted files put back after it) must read as a pipeline with no tabs and no not-sent marker; every job survives or is inremoved_jobswith a reason; no script command disappears unless a change names the job; a new job needs a change that explains it; every non-info flag is fixed or kept exactly once, and a flag claimed as fixed must be gone; no new high flag appears;readyis refused while a flag is kept or the file still raises high or medium flags; every variable asked for appears in the file or the paste and is not a predefinedCI_/GITLAB_one; an audit handoff’s findings are all changed or kept. - Both: no secret from the paste is repeated anywhere in the reply.
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}'
import hashlib, json, os, time, urllib.parse
import requests # pip install requests
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # step 2
class ApiError(Exception):
def __init__(self, status, code, message, details=None):
super().__init__(f"{status} {code}: {message}")
self.status, self.code, self.details = status, code, details
def api(method, path, body=None, headers=None):
h = {"Content-Type": "application/json"}
if TOKEN:
h["Authorization"] = "Bearer " + TOKEN
h.update(headers or {})
r = requests.request(method, BASE + path, headers=h,
data=None if body is None else json.dumps(body))
try:
env = r.json()
except ValueError:
env = {}
if not r.ok:
err = env.get("error") or {}
raise ApiError(r.status_code, err.get("code"), err.get("message") or r.reason, err.get("details"))
return env.get("data")
// gitlab-ci.mjs -- Node 18+ (global fetch), run with: node gitlab-ci.mjs
import { readFileSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // step 2
async function api(method, path, body, headers = {}) {
const h = { "Content-Type": "application/json", ...headers };
if (TOKEN) h.Authorization = "Bearer " + TOKEN;
const res = await fetch(BASE + path, {
method,
headers: h,
body: body === undefined ? undefined : JSON.stringify(body),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
const err = new Error((json.error && json.error.message) || res.statusText);
err.status = res.status;
err.code = json.error && json.error.code;
err.details = json.error && json.error.details;
throw err;
}
return json.data;
}
package main
import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"time"
)
const base = "https://api.skillsafe.ai/v1/app-api"
var token = os.Getenv("SKILLSAFE_TOKEN") // step 2
type apiError struct {
Status int
Code string
Message string
}
func (e *apiError) Error() string { return fmt.Sprintf("%d %s: %s", e.Status, e.Code, e.Message) }
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func newRequest(method, path string, body any, headers map[string]string) (*http.Request, error) {
var buf []byte
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return nil, err
}
buf = b
}
req, err := http.NewRequest(method, base+path, bytes.NewReader(buf))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
for k, v := range headers {
req.Header.Set(k, v)
}
return req, nil
}
func errorFrom(res *http.Response, env envelope) error {
e := &apiError{Status: res.StatusCode, Message: http.StatusText(res.StatusCode)}
if env.Error != nil {
e.Code = env.Error.Code
if env.Error.Message != "" {
e.Message = env.Error.Message
}
}
return e
}
// api sends body as JSON and decodes the envelope's data into out (out may be nil).
func api(method, path string, body any, headers map[string]string, out any) error {
req, err := newRequest(method, path, body, headers)
if err != nil {
return err
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
var env envelope
_ = json.NewDecoder(res.Body).Decode(&env)
if res.StatusCode < 200 || res.StatusCode > 299 {
return errorFrom(res, env)
}
if out != nil && len(env.Data) > 0 {
return json.Unmarshal(env.Data, out)
}
return nil
}
func main() {
// steps 2-6 go here; remove any import a step you skip no longer uses
_, _, _, _, _ = bufio.NewScanner, sha256.Sum256, hex.EncodeToString, url.PathEscape, strings.Index
_ = time.Second
}
// Pushproof.java -- Java 17+, needs com.fasterxml.jackson.core:jackson-databind on the classpath
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class Pushproof {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = System.getenv().getOrDefault("SKILLSAFE_TOKEN", "YOUR_TOKEN"); // step 2
static final HttpClient HTTP = HttpClient.newHttpClient();
static final ObjectMapper JSON = new ObjectMapper();
static class ApiException extends Exception {
final int status;
final String code;
ApiException(int status, String code, String message) {
super(status + " " + code + ": " + message);
this.status = status;
this.code = code;
}
}
static HttpRequest.Builder request(String method, String path, Object body, Map<String, String> headers) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Content-Type", "application/json")
.method(method, body == null ? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(JSON.writeValueAsString(body)));
if (token != null && !token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (headers != null) headers.forEach(b::header);
return b;
}
static ApiException errorFrom(int status, JsonNode env) {
JsonNode err = env.path("error");
return new ApiException(status, err.path("code").asText(null), err.path("message").asText("HTTP " + status));
}
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
HttpResponse<String> res = HTTP.send(request(method, path, body, headers).build(), HttpResponse.BodyHandlers.ofString());
JsonNode env;
try { env = JSON.readTree(res.body()); } catch (Exception e) { env = JSON.createObjectNode(); }
if (env == null) env = JSON.createObjectNode();
if (res.statusCode() / 100 != 2) throw errorFrom(res.statusCode(), env);
return env.path("data");
}
public static void main(String[] args) throws Exception {
// steps 2-6 go here (makeInput from step 4 goes next to api())
}
}
require "digest"
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
$token = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN") # step 2
class ApiError < StandardError
attr_reader :status, :code
def initialize(status, code, message)
super("#{status} #{code}: #{message}")
@status = status
@code = code
end
end
def build_request(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{$token}" if $token && !$token.empty?
headers.each { |k, v| req[k] = v }
req.body = JSON.generate(body) unless body.nil?
[uri, req]
end
def api(method, path, body = nil, headers = {})
uri, req = build_request(method, path, body, headers)
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
env = (JSON.parse(res.body) rescue {})
env = {} unless env.is_a?(Hash)
unless res.is_a?(Net::HTTPSuccess)
err = env["error"] || {}
raise ApiError.new(res.code.to_i, err["code"], err["message"] || res.message)
end
env["data"]
end
<?php
// needs the curl extension
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN"; // step 2
class ApiError extends Exception {
public $status;
public $apiCode;
public function __construct($status, $code, $message) {
parent::__construct("$status $code: $message");
$this->status = $status;
$this->apiCode = $code;
}
}
function headers_for(array $extra = []): array {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($extra as $k => $v) $h[] = "$k: $v";
return $h;
}
function api(string $method, string $path, $body = null, array $headers = []) {
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => headers_for($headers),
CURLOPT_RETURNTRANSFER => true,
]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
$raw = curl_exec($ch);
if ($raw === false) throw new RuntimeException(curl_error($ch));
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
$env = json_decode($raw);
if (!is_object($env)) $env = new stdClass();
if ($status < 200 || $status > 299) {
$e = $env->error ?? new stdClass();
throw new ApiError($status, $e->code ?? null, $e->message ?? "HTTP $status");
}
return $env->data ?? null;
}
// Program.cs -- .NET 6+ console app (implicit usings on) with top-level statements; steps 2-6 (with the MakeInput local function) go ABOVE this class
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
static class Api
{
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static string Token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN"; // step 2
public static readonly HttpClient Http = new HttpClient();
public class ApiException : Exception
{
public int Status;
public string? Code;
public ApiException(int status, string? code, string message) : base($"{status} {code}: {message}")
{
Status = status;
Code = code;
}
}
public static HttpRequestMessage Request(string method, string path, object? body = null, IDictionary<string, string>? headers = null)
{
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (body != null) req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
if (!string.IsNullOrEmpty(Token)) req.Headers.TryAddWithoutValidation("Authorization", "Bearer " + Token);
if (headers != null) foreach (var kv in headers) req.Headers.TryAddWithoutValidation(kv.Key, kv.Value);
return req;
}
public static ApiException ErrorFrom(HttpResponseMessage res, JsonNode? env)
{
var err = env?["error"];
return new ApiException((int)res.StatusCode, err?["code"]?.GetValue<string>(),
err?["message"]?.GetValue<string>() ?? res.ReasonPhrase ?? "error");
}
public static async Task<JsonNode?> Call(string method, string path, object? body = null, IDictionary<string, string>? headers = null)
{
using var req = Request(method, path, body, headers);
using var res = await Http.SendAsync(req);
var text = await res.Content.ReadAsStringAsync();
JsonNode? env = null;
try { env = JsonNode.Parse(text); } catch (JsonException) { }
if (!res.IsSuccessStatusCode) throw ErrorFrom(res, env);
return env?["data"];
}
}
Step 2 — a token
Two kinds work:
-
Personal — needed for a run. Both lanes are metered in credits, so a run
needs a signed-in user (the page says “The Audit run is metered, so it needs an
account.”) unless the estimate says
sponsor_enabled. Sign in and copy your token from /tokens.html, then export it asSKILLSAFE_TOKEN(the samples fall back to the placeholderYOUR_TOKEN). -
Guest — enough for
/meand/estimate.POST /guestwith{"slug": "pushproof"}and noAuthorizationheader, exactly assdk.jsguest()does. The response’sdatacarriestokenandguest_id. Reuse a token you already hold rather than minting a new one each time.
# 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}..."
# personal token (needed for a run): set SKILLSAFE_TOKEN (from /tokens.html) and skip this
if TOKEN in ("", "YOUR_TOKEN"):
TOKEN = "" # no Authorization header on /guest
guest = api("POST", "/guest", {"slug": "pushproof"})
TOKEN = guest["token"]
print("guest", guest.get("guest_id"))
// personal token (needed for a run): paste it from /tokens.html into TOKEN and skip this
if (!TOKEN || TOKEN === "YOUR_TOKEN") {
TOKEN = ""; // no Authorization header on /guest
const guest = await api("POST", "/guest", { slug: "pushproof" });
TOKEN = guest.token;
console.log("guest", guest.guest_id);
}
// inside main -- personal token (needed for a run): set SKILLSAFE_TOKEN (from /tokens.html) and skip this
if token == "" {
var guest struct {
Token string `json:"token"`
GuestID string `json:"guest_id"`
}
if err := api("POST", "/guest", map[string]string{"slug": "pushproof"}, nil, &guest); err != nil {
panic(err)
}
token = guest.Token
fmt.Println("guest", guest.GuestID)
}
// inside main -- personal token (needed for a run): set SKILLSAFE_TOKEN (from /tokens.html) and skip this
if (token.isEmpty() || token.equals("YOUR_TOKEN")) {
token = ""; // no Authorization header on /guest
JsonNode guest = api("POST", "/guest", Map.of("slug", "pushproof"), null);
token = guest.path("token").asText();
System.out.println("guest " + guest.path("guest_id").asText());
}
# personal token (needed for a run): set SKILLSAFE_TOKEN (from /tokens.html) and skip this
if $token.empty? || $token == "YOUR_TOKEN"
$token = "" # no Authorization header on /guest
guest = api("POST", "/guest", { slug: "pushproof" })
$token = guest["token"]
puts "guest #{guest["guest_id"]}"
end
// personal token (needed for a run): set SKILLSAFE_TOKEN (from /tokens.html) and skip this
if ($TOKEN === "" || $TOKEN === "YOUR_TOKEN") {
$TOKEN = ""; // no Authorization header on /guest
$guest = api("POST", "/guest", ["slug" => "pushproof"]);
$TOKEN = $guest->token;
echo "guest {$guest->guest_id}\n";
}
// top-level statements (above the Api class) -- personal token (needed for a run): set SKILLSAFE_TOKEN (from /tokens.html) and skip this
if (Api.Token == "" || Api.Token == "YOUR_TOKEN")
{
Api.Token = ""; // no Authorization header on /guest
var guest = await Api.Call("POST", "/guest", new { slug = "pushproof" });
Api.Token = guest!["token"]!.GetValue<string>();
Console.WriteLine($"guest {guest["guest_id"]}");
}
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'
me = api("GET", "/me")
print(me["subject_type"], me.get("subject_id"), me.get("credits"))
signed_in = me["subject_type"] == "user"
const me = await api("GET", "/me");
console.log(me.subject_type, me.subject_id, me.credits);
const signedIn = me.subject_type === "user";
var me struct {
SubjectType string `json:"subject_type"`
SubjectID any `json:"subject_id"`
Credits any `json:"credits"`
}
if err := api("GET", "/me", nil, nil, &me); err != nil {
panic(err)
}
fmt.Println(me.SubjectType, me.SubjectID, me.Credits)
signedIn := me.SubjectType == "user"
_ = signedIn
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.path("subject_type").asText() + " " + me.path("subject_id").asText() + " " + me.path("credits"));
boolean signedIn = me.path("subject_type").asText().equals("user");
me = api("GET", "/me")
puts [me["subject_type"], me["subject_id"], me["credits"]].join(" ")
signed_in = me["subject_type"] == "user"
$me = api("GET", "/me");
echo $me->subject_type, " ", $me->subject_id ?? "", " ", $me->credits ?? "", "\n";
$signedIn = $me->subject_type === "user";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me?["subject_type"]} {me?["subject_id"]} {me?["credits"]}");
var signedIn = me?["subject_type"]?.GetValue<string>() == "user";
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:
| Field | Meaning |
|---|---|
hold_credits | A 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_credits | The smallest balance that can start a run. Below it the page disables the run button, because the call would be a 402. |
sponsor_enabled | When true, a caller who is not signed in may run; otherwise the page asks them to sign in. |
warnings | Input 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})'
def make_input(task, config_path, facts_path, note=None, handoff=None):
"""The body the page sends: task, config (the YAML text), facts as a JSON STRING, an optional note,
and (rewrite lane only) the audit_handoff string."""
with open(config_path, encoding="utf-8", newline="") as f:
config = f.read() # the pipeline, verbatim
with open(facts_path, encoding="utf-8") as f:
facts = json.load(f) # the page's free check ({} if you have none)
inp = {
"task": task,
"config": config,
"facts": json.dumps(facts, separators=(",", ":"), ensure_ascii=False), # a STRING, like JSON.stringify
}
n = (note or "").strip()
if n:
inp["note"] = n[:2000] # optional; the page caps it at 2000 chars
if task == "rewrite" and handoff:
inp["audit_handoff"] = handoff # optional; a JSON string built from an audit reply
return inp
inp = make_input("audit", "storefront-config.yml", "storefront-facts.json",
"Our e2e job never shows up and GitLab says the config is invalid. Also - is it OK that anyone can deploy?")
est = api("POST", "/estimate", inp)
print("hold", est.get("hold_credits"), "min", est.get("min_credits"),
"sponsored", est.get("sponsor_enabled"), "warnings", est.get("warnings") or [])
// The body the page sends: task, config (the YAML text), facts as a JSON STRING, an optional note,
// and (rewrite lane only) the audit_handoff string.
function makeInput(task, configPath, factsPath, note, handoff) {
const input = {
task,
config: readFileSync(configPath, "utf8"), // the pipeline, verbatim
facts: JSON.stringify(JSON.parse(readFileSync(factsPath, "utf8"))), // a STRING: the page's free check
};
const n = (note || "").trim();
if (n) input.note = n.length > 2000 ? n.slice(0, 2000) : n; // optional; max 2000 chars
if (task === "rewrite" && handoff) input.audit_handoff = handoff; // optional; a JSON string
return input;
}
const input = makeInput("audit", "storefront-config.yml", "storefront-facts.json",
"Our e2e job never shows up and GitLab says the config is invalid. Also - is it OK that anyone can deploy?");
const est = await api("POST", "/estimate", input);
console.log("hold", est.hold_credits, "min", est.min_credits,
"sponsored", est.sponsor_enabled, "warnings", est.warnings || []);
// makeInput builds the body the page sends: task, config (the YAML text), facts as a JSON STRING,
// an optional note and (rewrite lane only) the audit_handoff string.
makeInput := func(task, configPath, factsPath, note, handoff string) map[string]any {
read := func(path string) []byte {
raw, err := os.ReadFile(path)
if err != nil {
panic(err)
}
return raw
}
var facts bytes.Buffer
if err := json.Compact(&facts, read(factsPath)); err != nil { // keeps key order; travels as a STRING
panic(err)
}
in := map[string]any{"task": task, "config": string(read(configPath)), "facts": facts.String()}
if n := []rune(strings.TrimSpace(note)); len(n) > 0 {
if len(n) > 2000 {
n = n[:2000] // optional; the page caps it at 2000 chars
}
in["note"] = string(n)
}
if task == "rewrite" && handoff != "" {
in["audit_handoff"] = handoff // optional; a JSON string built from an audit reply
}
return in
}
input := makeInput("audit", "storefront-config.yml", "storefront-facts.json",
"Our e2e job never shows up and GitLab says the config is invalid. Also - is it OK that anyone can deploy?", "")
var est struct {
HoldCredits any `json:"hold_credits"`
MinCredits any `json:"min_credits"`
SponsorEnabled bool `json:"sponsor_enabled"`
Warnings []any `json:"warnings"`
}
if err := api("POST", "/estimate", input, nil, &est); err != nil {
panic(err)
}
fmt.Printf("hold %v min %v sponsored %v warnings %v\n", est.HoldCredits, est.MinCredits, est.SponsorEnabled, est.Warnings)
// ---- add to the Pushproof class, next to api(): the body the page sends
static Map<String, Object> makeInput(String task, String configPath, String factsPath, String note, String handoff) throws Exception {
Map<String, Object> in = new LinkedHashMap<>();
in.put("task", task);
in.put("config", Files.readString(Path.of(configPath))); // the pipeline, verbatim
in.put("facts", JSON.writeValueAsString(JSON.readTree(Files.readString(Path.of(factsPath))))); // a STRING, compact
String n = note == null ? "" : note.strip();
if (!n.isEmpty()) in.put("note", n.length() > 2000 ? n.substring(0, 2000) : n); // optional; max 2000 chars
if (task.equals("rewrite") && handoff != null && !handoff.isEmpty()) in.put("audit_handoff", handoff); // optional
return in;
}
// ---- inside main
Map<String, Object> input = makeInput("audit", "storefront-config.yml", "storefront-facts.json",
"Our e2e job never shows up and GitLab says the config is invalid. Also - is it OK that anyone can deploy?", null);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println("hold " + est.path("hold_credits") + " min " + est.path("min_credits")
+ " sponsored " + est.path("sponsor_enabled").asBoolean(false) + " warnings " + est.path("warnings"));
# The body the page sends: task, config (the YAML text), facts as a JSON STRING, an optional note,
# and (rewrite lane only) the audit_handoff string.
def make_input(task, config_path, facts_path, note = nil, handoff = nil)
input = {
task: task,
config: File.read(config_path, encoding: "UTF-8"), # the pipeline, verbatim
facts: JSON.generate(JSON.parse(File.read(facts_path, encoding: "UTF-8"))) # a STRING, compact
}
n = note.to_s.strip
input[:note] = n[0, 2000] unless n.empty? # optional; max 2000 chars
input[:audit_handoff] = handoff if task == "rewrite" && handoff && !handoff.empty? # optional
input
end
input = make_input("audit", "storefront-config.yml", "storefront-facts.json",
"Our e2e job never shows up and GitLab says the config is invalid. Also - is it OK that anyone can deploy?")
est = api("POST", "/estimate", input)
puts "hold #{est["hold_credits"]} min #{est["min_credits"]} " \
"sponsored #{est["sponsor_enabled"]} warnings #{(est["warnings"] || []).inspect}"
// The body the page sends: task, config (the YAML text), facts as a JSON STRING, an optional note,
// and (rewrite lane only) the audit_handoff string.
function make_input(string $task, string $configPath, string $factsPath, string $note = "", string $handoff = ""): array {
// decode to objects (not arrays) so an empty {} stays {} when re-encoded
$facts = json_encode(json_decode(file_get_contents($factsPath)), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$input = ["task" => $task, "config" => file_get_contents($configPath), "facts" => $facts]; // all STRINGS
$n = trim($note);
if ($n !== "") $input["note"] = mb_substr($n, 0, 2000); // optional; max 2000 chars
if ($task === "rewrite" && $handoff !== "") $input["audit_handoff"] = $handoff; // optional
return $input;
}
$input = make_input("audit", "storefront-config.yml", "storefront-facts.json",
"Our e2e job never shows up and GitLab says the config is invalid. Also - is it OK that anyone can deploy?");
$est = api("POST", "/estimate", $input);
echo "hold ", $est->hold_credits ?? "-", " min ", $est->min_credits ?? "-",
" sponsored ", json_encode($est->sponsor_enabled ?? false),
" warnings ", json_encode($est->warnings ?? []), "\n";
// The body the page sends: task, config (the YAML text), facts as a JSON STRING, an optional note,
// and (rewrite lane only) the audit_handoff string.
static Dictionary<string, string> MakeInput(string task, string configPath, string factsPath, string? note = null, string? handoff = null)
{
var compact = new JsonSerializerOptions { Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
var input = new Dictionary<string, string>
{
["task"] = task,
["config"] = File.ReadAllText(configPath), // the pipeline, verbatim
["facts"] = JsonNode.Parse(File.ReadAllText(factsPath))!.ToJsonString(compact), // a STRING, compact
};
var n = (note ?? "").Trim();
if (n.Length > 0) input["note"] = n.Length > 2000 ? n[..2000] : n; // optional; max 2000 chars
if (task == "rewrite" && !string.IsNullOrEmpty(handoff)) input["audit_handoff"] = handoff; // optional
return input;
}
var input = MakeInput("audit", "storefront-config.yml", "storefront-facts.json",
"Our e2e job never shows up and GitLab says the config is invalid. Also - is it OK that anyone can deploy?");
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"hold {est?["hold_credits"]} min {est?["min_credits"]} " +
$"sponsored {est?["sponsor_enabled"]} warnings {est?["warnings"]?.ToJsonString() ?? "[]"}");
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:
output.output— the model’s reply as a string: the JSON object from the output contract of the lane you asked for. Parse it yourself (from the first{to the last}). The samples print the verdict and headline, each finding’sid,block,lineandseverity, then each described job.truncated—truewhen the reply was cut short because the balance ran out. The page then closes the cut-off JSON, shows what arrived as a partial result (“N of 8 sections recovered” for an audit, of 9 for a rewrite), and says it was cut short by the available balance.charged_credits— the real cost of the run, as opposed to the estimate’shold_creditsreservation. The page re-reads/meafterwards for the new balance.error— on afailedjob.
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]}'
body = json.dumps(inp)
key = "pushproof:audit:" + hashlib.sha256(body.encode()).hexdigest()[:16] + ":a1"
job_id = api("POST", "/run", inp, {"Idempotency-Key": key})["job_id"]
deadline = time.time() + 180 # 1 s interval, 180 s client-side limit
while True:
job = api("GET", "/jobs/" + urllib.parse.quote(job_id, safe=""))
if job["status"] in ("succeeded", "failed"):
break
if time.time() > deadline:
raise TimeoutError("job timed out")
time.sleep(1)
if job["status"] == "failed":
raise SystemExit(f"run failed: {job.get('error')}")
print("truncated:", job.get("truncated") is True, "charged:", job.get("charged_credits"))
text = (job.get("output") or {}).get("output") or ""
reply = json.loads(text[text.index("{"): text.rindex("}") + 1])
print(reply["verdict"], "-", reply["headline"])
for f in reply.get("findings", []):
print(f["id"], f["block"], f.get("line"), f["severity"])
for j in reply.get("jobs", []):
print("job", j["job"])
const key = "pushproof:audit:" +
createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16) + ":a1";
const { job_id } = await api("POST", "/run", input, { "Idempotency-Key": key });
let job;
const deadline = Date.now() + 180000; // 1 s interval, 180 s client-side limit
for (;;) {
job = await api("GET", "/jobs/" + encodeURIComponent(job_id));
if (job.status === "succeeded" || job.status === "failed") break;
if (Date.now() > deadline) throw new Error("job timed out");
await new Promise((r) => setTimeout(r, 1000));
}
if (job.status === "failed") throw new Error("run failed: " + JSON.stringify(job.error));
console.log("truncated:", job.truncated === true, "charged:", job.charged_credits);
const text = (job.output && job.output.output) || "";
const reply = JSON.parse(text.slice(text.indexOf("{"), text.lastIndexOf("}") + 1));
console.log(reply.verdict, "-", reply.headline);
for (const f of reply.findings || []) console.log(f.id, f.block, f.line, f.severity);
for (const j of reply.jobs || []) console.log("job", j.job);
inputJSON, _ := json.Marshal(input)
sum := sha256.Sum256(inputJSON)
key := "pushproof:audit:" + hex.EncodeToString(sum[:8]) + ":a1"
var started struct {
JobID string `json:"job_id"`
}
if err := api("POST", "/run", input, map[string]string{"Idempotency-Key": key}, &started); err != nil {
panic(err)
}
type jobT struct {
Status string `json:"status"`
Output *struct {
Output string `json:"output"`
} `json:"output"`
Truncated bool `json:"truncated"`
ChargedCredits any `json:"charged_credits"`
Error any `json:"error"`
}
var job jobT
deadline := time.Now().Add(180 * time.Second) // 1 s interval, 180 s client-side limit
for {
job = jobT{}
if err := api("GET", "/jobs/"+url.PathEscape(started.JobID), nil, nil, &job); err != nil {
panic(err)
}
if job.Status == "succeeded" || job.Status == "failed" {
break
}
if time.Now().After(deadline) {
panic("job timed out")
}
time.Sleep(time.Second)
}
if job.Status == "failed" {
panic(fmt.Sprintf("run failed: %v", job.Error))
}
fmt.Println("truncated:", job.Truncated, "charged:", job.ChargedCredits)
text := ""
if job.Output != nil {
text = job.Output.Output
}
var reply map[string]any
if a, b := strings.Index(text, "{"), strings.LastIndex(text, "}"); a >= 0 && b > a {
if err := json.Unmarshal([]byte(text[a:b+1]), &reply); err != nil {
panic(err)
}
}
fmt.Println(reply["verdict"], "-", reply["headline"])
findings, _ := reply["findings"].([]any)
for _, e := range findings {
f, _ := e.(map[string]any)
fmt.Println(f["id"], f["block"], f["line"], f["severity"])
}
jobs, _ := reply["jobs"].([]any)
for _, e := range jobs {
j, _ := e.(map[string]any)
fmt.Println("job", j["job"])
}
byte[] digest = MessageDigest.getInstance("SHA-256").digest(JSON.writeValueAsBytes(input));
String key = "pushproof:audit:" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
String jobId = api("POST", "/run", input, Map.of("Idempotency-Key", key)).path("job_id").asText();
JsonNode job;
long deadline = System.currentTimeMillis() + 180_000; // 1 s interval, 180 s client-side limit
while (true) {
job = api("GET", "/jobs/" + URLEncoder.encode(jobId, StandardCharsets.UTF_8), null, null);
String s = job.path("status").asText();
if (s.equals("succeeded") || s.equals("failed")) break;
if (System.currentTimeMillis() > deadline) throw new RuntimeException("job timed out");
Thread.sleep(1000);
}
if (job.path("status").asText().equals("failed")) throw new RuntimeException("run failed: " + job.path("error"));
System.out.println("truncated: " + job.path("truncated").asBoolean(false) + ", charged: " + job.path("charged_credits"));
String text = job.path("output").path("output").asText("");
JsonNode reply = JSON.readTree(text.substring(text.indexOf('{'), text.lastIndexOf('}') + 1));
System.out.println(reply.path("verdict").asText() + " - " + reply.path("headline").asText());
for (JsonNode f : reply.path("findings")) {
System.out.println(f.path("id").asText() + " " + f.path("block").asText() + " " + f.path("line") + " " + f.path("severity").asText());
}
for (JsonNode j : reply.path("jobs")) {
System.out.println("job " + j.path("job").asText());
}
key = "pushproof:audit:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job_id = api("POST", "/run", input, { "Idempotency-Key" => key })["job_id"]
deadline = Time.now + 180 # 1 s interval, 180 s client-side limit
job = nil
loop do
job = api("GET", "/jobs/#{URI.encode_www_form_component(job_id)}")
break if %w[succeeded failed].include?(job["status"])
raise "job timed out" if Time.now > deadline
sleep 1
end
raise "run failed: #{job["error"].inspect}" if job["status"] == "failed"
puts "truncated: #{job["truncated"] == true}, charged: #{job["charged_credits"]}"
text = (job["output"] || {})["output"].to_s
reply = JSON.parse(text[text.index("{")..text.rindex("}")])
puts "#{reply["verdict"]} - #{reply["headline"]}"
(reply["findings"] || []).each { |f| puts "#{f["id"]} #{f["block"]} #{f["line"].inspect} #{f["severity"]}" }
(reply["jobs"] || []).each { |j| puts "job #{j["job"]}" }
$key = "pushproof:audit:" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$jobId = api("POST", "/run", $input, ["Idempotency-Key" => $key])->job_id;
$deadline = time() + 180; // 1 s interval, 180 s client-side limit
while (true) {
$job = api("GET", "/jobs/" . rawurlencode($jobId));
if (in_array($job->status, ["succeeded", "failed"], true)) break;
if (time() > $deadline) throw new RuntimeException("job timed out");
sleep(1);
}
if ($job->status === "failed") throw new RuntimeException("run failed: " . json_encode($job->error ?? null));
echo "truncated: ", json_encode(($job->truncated ?? false) === true), ", charged: ", $job->charged_credits ?? "-", "\n";
$text = $job->output->output ?? "";
$reply = json_decode(substr($text, strpos($text, "{"), strrpos($text, "}") - strpos($text, "{") + 1));
echo $reply->verdict, " - ", $reply->headline, "\n";
foreach ($reply->findings ?? [] as $f) echo $f->id, " ", $f->block, " ", json_encode($f->line ?? null), " ", $f->severity, "\n";
foreach ($reply->jobs ?? [] as $j) echo "job ", $j->job, "\n";
var digest = System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(input)));
var key = "pushproof:audit:" + Convert.ToHexString(digest, 0, 8).ToLowerInvariant() + ":a1";
var started = await Api.Call("POST", "/run", input, new Dictionary<string, string> { ["Idempotency-Key"] = key });
var jobId = started!["job_id"]!.GetValue<string>();
JsonNode job;
var deadline = DateTime.UtcNow.AddSeconds(180); // 1 s interval, 180 s client-side limit
while (true)
{
job = (await Api.Call("GET", "/jobs/" + Uri.EscapeDataString(jobId)))!;
var s = job["status"]?.GetValue<string>();
if (s == "succeeded" || s == "failed") break;
if (DateTime.UtcNow > deadline) throw new TimeoutException("job timed out");
await Task.Delay(1000);
}
if (job["status"]?.GetValue<string>() == "failed") throw new Exception($"run failed: {job["error"]?.ToJsonString()}");
var truncated = job["truncated"] is JsonValue t && t.TryGetValue(out bool tb) && tb;
Console.WriteLine($"truncated: {truncated}, charged: {job["charged_credits"]}");
var text = job["output"]?["output"]?.GetValue<string>() ?? "";
var reply = JsonNode.Parse(text[text.IndexOf('{')..(text.LastIndexOf('}') + 1)])!;
Console.WriteLine($"{reply["verdict"]} - {reply["headline"]}");
foreach (var f in reply["findings"]?.AsArray() ?? new JsonArray())
Console.WriteLine($"{f?["id"]} {f?["block"]} {f?["line"]?.ToJsonString() ?? "null"} {f?["severity"]}");
foreach (var j in reply["jobs"]?.AsArray() ?? new JsonArray())
Console.WriteLine($"job {j?["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:
| Event | Data | What the SDK does |
|---|---|---|
delta | {text} | Passes text to onDelta. |
job | job info | Passes it to onJob (the page uses it to advance its progress display). |
done | the final payload: {job_id, status, charged_credits, output} | Keeps it as the result. |
pending | a payload | Treated 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
# the rewrite worked example: terraform-config.yml and terraform-facts.json from build-facts.js
# (for a rewrite of an audited paste, pass handoff=json.dumps(...) built as in "The audit_handoff string")
rw_inp = make_input("rewrite", "terraform-config.yml", "terraform-facts.json",
"Please make this safe: apply should only happen after someone reads the plan, and the state password must not be in the repo.")
rw_key = "pushproof:rewrite:" + hashlib.sha256(json.dumps(rw_inp).encode()).hexdigest()[:16] + ":a1"
h = {"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN, "Idempotency-Key": rw_key}
done, failure, streamed = None, None, ""
with requests.post(BASE + "/run-stream", headers=h, data=json.dumps(rw_inp), stream=True) as r:
if "text/event-stream" not in r.headers.get("content-type", ""):
try:
env = r.json()
except ValueError:
env = {}
if not r.ok:
err = env.get("error") or {}
raise ApiError(r.status_code, err.get("code"), err.get("message") or r.reason)
done = env.get("data")
else:
event, data = "message", ""
for line in r.iter_lines(decode_unicode=True):
if line == "": # a blank line ends a frame
if data:
try:
payload = json.loads(data)
except ValueError:
payload = None
if event == "delta" and payload is not None:
streamed += payload.get("text") or ""
elif event == "job":
print("job", payload)
elif event in ("done", "pending"):
done = payload
elif event == "error":
failure = payload
event, data = "message", ""
elif line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("data:"):
data += line[5:].strip()
if failure:
raise SystemExit(f"{failure.get('code')}: {failure.get('message')} (job {failure.get('job_id')})")
done = done or {}
full = (done.get("output") or {}).get("output") or streamed
print("truncated:", done.get("truncated") is True, "charged:", done.get("charged_credits"))
result = json.loads(full[full.index("{"): full.rindex("}") + 1])
print(result["verdict"], "-", result["headline"])
for c in result.get("changes", []):
print("change", ",".join(c.get("flags") or []), c.get("id"), c.get("block"))
for k in result.get("kept", []):
print("kept", k["flag"], "-", k.get("reason"))
for v in result.get("variables", []):
print("variable", v["name"], "masked=" + str(v.get("masked")), "protected=" + str(v.get("protected")))
with open("rewritten.gitlab-ci.yml", "w", encoding="utf-8") as f:
f.write(result.get("pipeline") or "")
// the rewrite worked example: terraform-config.yml and terraform-facts.json from build-facts.js
// (for a rewrite of an audited paste, pass the audit_handoff string as the last argument)
const rwInput = makeInput("rewrite", "terraform-config.yml", "terraform-facts.json",
"Please make this safe: apply should only happen after someone reads the plan, and the state password must not be in the repo.");
const rwKey = "pushproof:rewrite:" +
createHash("sha256").update(JSON.stringify(rwInput)).digest("hex").slice(0, 16) + ":a1";
const res = await fetch(BASE + "/run-stream", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer " + TOKEN, "Idempotency-Key": rwKey },
body: JSON.stringify(rwInput),
});
let done = null, failure = null, streamed = "";
if (!(res.headers.get("content-type") || "").includes("text/event-stream")) {
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error((json.error && json.error.message) || res.statusText);
done = json.data;
} else {
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
for (;;) {
const chunk = await reader.read();
if (chunk.done) break;
buffer += decoder.decode(chunk.value, { stream: true });
let idx;
while ((idx = buffer.indexOf("\n\n")) >= 0) {
const frame = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
let event = "message", dataStr = "";
for (const line of frame.split("\n")) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) dataStr += line.slice(5).trim();
}
if (!dataStr) continue;
let data;
try { data = JSON.parse(dataStr); } catch { continue; }
if (event === "delta") streamed += data.text || "";
else if (event === "job") console.log("job", data);
else if (event === "done" || event === "pending") done = data;
else if (event === "error") failure = data;
}
}
}
if (failure) throw new Error(`${failure.code}: ${failure.message} (job ${failure.job_id})`);
const full = (done && done.output && done.output.output) || streamed;
console.log("truncated:", !!(done && done.truncated), "charged:", done && done.charged_credits);
const result = JSON.parse(full.slice(full.indexOf("{"), full.lastIndexOf("}") + 1));
console.log(result.verdict, "-", result.headline);
for (const c of result.changes || []) console.log("change", (c.flags || []).join(","), c.id, c.block);
for (const k of result.kept || []) console.log("kept", k.flag, "-", k.reason);
for (const v of result.variables || []) console.log("variable", v.name, "masked=" + v.masked, "protected=" + v.protected);
writeFileSync("rewritten.gitlab-ci.yml", result.pipeline || "");
// the rewrite worked example: terraform-config.yml and terraform-facts.json from build-facts.js
// (for a rewrite of an audited paste, pass the audit_handoff string as the last argument)
rwInput := makeInput("rewrite", "terraform-config.yml", "terraform-facts.json",
"Please make this safe: apply should only happen after someone reads the plan, and the state password must not be in the repo.", "")
rwJSON, _ := json.Marshal(rwInput)
rwSum := sha256.Sum256(rwJSON)
rwKey := "pushproof:rewrite:" + hex.EncodeToString(rwSum[:8]) + ":a1"
sreq, err := newRequest("POST", "/run-stream", rwInput, map[string]string{"Idempotency-Key": rwKey})
if err != nil {
panic(err)
}
sres, err := http.DefaultClient.Do(sreq)
if err != nil {
panic(err)
}
defer sres.Body.Close()
var done, failure map[string]any
var streamed strings.Builder
if !strings.Contains(sres.Header.Get("Content-Type"), "text/event-stream") {
var env envelope
_ = json.NewDecoder(sres.Body).Decode(&env)
if sres.StatusCode < 200 || sres.StatusCode > 299 {
panic(errorFrom(sres, env))
}
_ = json.Unmarshal(env.Data, &done)
} else {
sc := bufio.NewScanner(sres.Body)
sc.Buffer(make([]byte, 64*1024), 16*1024*1024) // the done frame can be long
event, data := "message", ""
for sc.Scan() {
line := sc.Text()
switch {
case line == "": // a blank line ends a frame
if data != "" {
var p map[string]any
if json.Unmarshal([]byte(data), &p) == nil {
switch event {
case "delta":
if t, ok := p["text"].(string); ok {
streamed.WriteString(t)
}
case "job":
fmt.Println("job", p)
case "done", "pending":
done = p
case "error":
failure = p
}
}
}
event, data = "message", ""
case strings.HasPrefix(line, "event:"):
event = strings.TrimSpace(line[6:])
case strings.HasPrefix(line, "data:"):
data += strings.TrimSpace(line[5:])
}
}
}
if failure != nil {
panic(fmt.Sprintf("%v: %v (job %v)", failure["code"], failure["message"], failure["job_id"]))
}
full := streamed.String()
if out, ok := done["output"].(map[string]any); ok {
if s, ok := out["output"].(string); ok && s != "" {
full = s
}
}
fmt.Println("truncated:", done["truncated"] == true, "charged:", done["charged_credits"])
var result map[string]any
if a, b := strings.Index(full, "{"), strings.LastIndex(full, "}"); a >= 0 && b > a {
if err := json.Unmarshal([]byte(full[a:b+1]), &result); err != nil {
panic(err)
}
}
fmt.Println(result["verdict"], "-", result["headline"])
changes, _ := result["changes"].([]any)
for _, e := range changes {
c, _ := e.(map[string]any)
fl, _ := c["flags"].([]any)
parts := make([]string, 0, len(fl))
for _, u := range fl {
parts = append(parts, fmt.Sprint(u))
}
fmt.Println("change", strings.Join(parts, ","), c["id"], c["block"])
}
kept, _ := result["kept"].([]any)
for _, e := range kept {
k, _ := e.(map[string]any)
fmt.Println("kept", k["flag"], "-", k["reason"])
}
variables, _ := result["variables"].([]any)
for _, e := range variables {
v, _ := e.(map[string]any)
fmt.Printf("variable %v masked=%v protected=%v\n", v["name"], v["masked"], v["protected"])
}
pipeline, _ := result["pipeline"].(string)
if err := os.WriteFile("rewritten.gitlab-ci.yml", []byte(pipeline), 0o644); err != nil {
panic(err)
}
// the rewrite worked example: terraform-config.yml and terraform-facts.json from build-facts.js
// (for a rewrite of an audited paste, pass the audit_handoff string as the last argument)
Map<String, Object> rwInput = makeInput("rewrite", "terraform-config.yml", "terraform-facts.json",
"Please make this safe: apply should only happen after someone reads the plan, and the state password must not be in the repo.", null);
byte[] rwDigest = MessageDigest.getInstance("SHA-256").digest(JSON.writeValueAsBytes(rwInput));
String rwKey = "pushproof:rewrite:" + HexFormat.of().formatHex(rwDigest, 0, 8) + ":a1";
HttpRequest sreq = request("POST", "/run-stream", rwInput, Map.of("Idempotency-Key", rwKey)).build();
HttpResponse<Stream<String>> sres = HTTP.send(sreq, HttpResponse.BodyHandlers.ofLines());
JsonNode done = null, failure = null;
StringBuilder streamed = new StringBuilder();
if (!sres.headers().firstValue("content-type").orElse("").contains("text/event-stream")) {
JsonNode env = JSON.readTree(sres.body().collect(Collectors.joining("\n")));
if (env == null) env = JSON.createObjectNode();
if (sres.statusCode() / 100 != 2) throw errorFrom(sres.statusCode(), env);
done = env.path("data");
} else {
String event = "message";
StringBuilder data = new StringBuilder();
Iterator<String> it = sres.body().iterator();
while (it.hasNext()) {
String line = it.next();
if (line.isEmpty()) { // a blank line ends a frame
if (data.length() > 0) {
JsonNode p = null;
try { p = JSON.readTree(data.toString()); } catch (Exception ignored) { }
if (p != null) {
switch (event) {
case "delta": streamed.append(p.path("text").asText("")); break;
case "job": System.out.println("job " + p); break;
case "done": case "pending": done = p; break;
case "error": failure = p; break;
default: break;
}
}
}
event = "message";
data.setLength(0);
} else if (line.startsWith("event:")) event = line.substring(6).trim();
else if (line.startsWith("data:")) data.append(line.substring(5).trim());
}
}
if (failure != null) {
throw new RuntimeException(failure.path("code").asText() + ": " + failure.path("message").asText()
+ " (job " + failure.path("job_id").asText() + ")");
}
String full = done == null ? "" : done.path("output").path("output").asText("");
if (full.isEmpty()) full = streamed.toString();
System.out.println("truncated: " + (done != null && done.path("truncated").asBoolean(false))
+ ", charged: " + (done == null ? "-" : done.path("charged_credits").toString()));
JsonNode result = JSON.readTree(full.substring(full.indexOf('{'), full.lastIndexOf('}') + 1));
System.out.println(result.path("verdict").asText() + " - " + result.path("headline").asText());
for (JsonNode c : result.path("changes")) {
List<String> fl = new ArrayList<>();
c.path("flags").forEach(u -> fl.add(u.asText()));
System.out.println("change " + String.join(",", fl) + " " + c.path("id").asText() + " " + c.path("block").asText());
}
for (JsonNode k : result.path("kept")) {
System.out.println("kept " + k.path("flag").asText() + " - " + k.path("reason").asText());
}
for (JsonNode v : result.path("variables")) {
System.out.println("variable " + v.path("name").asText() + " masked=" + v.path("masked").asBoolean(false)
+ " protected=" + v.path("protected").asBoolean(false));
}
Files.writeString(Path.of("rewritten.gitlab-ci.yml"), result.path("pipeline").asText(""));
# the rewrite worked example: terraform-config.yml and terraform-facts.json from build-facts.js
# (for a rewrite of an audited paste, pass the audit_handoff string as the last argument)
rw_input = make_input("rewrite", "terraform-config.yml", "terraform-facts.json",
"Please make this safe: apply should only happen after someone reads the plan, and the state password must not be in the repo.")
rw_key = "pushproof:rewrite:#{Digest::SHA256.hexdigest(JSON.generate(rw_input))[0, 16]}:a1"
uri, req = build_request("POST", "/run-stream", rw_input, { "Idempotency-Key" => rw_key })
done = failure = nil
streamed = +""
Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 300) do |http|
http.request(req) do |res|
unless res["content-type"].to_s.include?("text/event-stream")
env = (JSON.parse(res.body) rescue {})
unless res.is_a?(Net::HTTPSuccess)
raise ApiError.new(res.code.to_i, env.dig("error", "code"), env.dig("error", "message") || res.message)
end
done = env["data"]
next
end
buf = +""
res.read_body do |chunk|
buf << chunk
while (i = buf.index("\n\n"))
frame = buf.slice!(0, i + 2)
event = "message"
data = +""
frame.split("\n").each do |line|
if line.start_with?("event:") then event = line[6..].strip
elsif line.start_with?("data:") then data << line[5..].strip
end
end
next if data.empty?
payload = (JSON.parse(data) rescue nil)
next if payload.nil?
case event
when "delta" then streamed << payload["text"].to_s
when "job" then puts "job #{payload.inspect}"
when "done", "pending" then done = payload
when "error" then failure = payload
end
end
end
end
end
raise "#{failure["code"]}: #{failure["message"]} (job #{failure["job_id"]})" if failure
done ||= {}
full = (done["output"] || {})["output"].to_s
full = streamed if full.empty?
puts "truncated: #{done["truncated"] == true}, charged: #{done["charged_credits"]}"
result = JSON.parse(full[full.index("{")..full.rindex("}")])
puts "#{result["verdict"]} - #{result["headline"]}"
(result["changes"] || []).each { |c| puts "change #{(c["flags"] || []).join(",")} #{c["id"]} #{c["block"]}" }
(result["kept"] || []).each { |k| puts "kept #{k["flag"]} - #{k["reason"]}" }
(result["variables"] || []).each { |v| puts "variable #{v["name"]} masked=#{v["masked"]} protected=#{v["protected"]}" }
File.write("rewritten.gitlab-ci.yml", result["pipeline"].to_s)
// the rewrite worked example: terraform-config.yml and terraform-facts.json from build-facts.js
// (for a rewrite of an audited paste, pass the audit_handoff string as the last argument)
$rwInput = make_input("rewrite", "terraform-config.yml", "terraform-facts.json",
"Please make this safe: apply should only happen after someone reads the plan, and the state password must not be in the repo.");
$rwKey = "pushproof:rewrite:" . substr(hash("sha256", json_encode($rwInput)), 0, 16) . ":a1";
$done = null; $failure = null; $streamed = ""; $buf = ""; $ctype = "";
$handle = function (string $event, string $data) use (&$done, &$failure, &$streamed) {
$p = json_decode($data);
if ($p === null) return; // not JSON: skipped, as sdk.js does
switch ($event) {
case "delta": $streamed .= $p->text ?? ""; break;
case "job": echo "job ", json_encode($p), "\n"; break;
case "done": case "pending": $done = $p; break;
case "error": $failure = $p; break;
}
};
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => headers_for(["Idempotency-Key" => $rwKey]),
CURLOPT_POSTFIELDS => json_encode($rwInput, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$ctype) {
if (stripos($line, "content-type:") === 0) $ctype = strtolower(trim(substr($line, 13)));
return strlen($line);
},
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$buf, &$ctype, $handle) {
$buf .= $chunk;
if (strpos($ctype, "text/event-stream") === false) return strlen($chunk); // plain JSON: parse at the end
while (($i = strpos($buf, "\n\n")) !== false) {
$frame = substr($buf, 0, $i);
$buf = substr($buf, $i + 2);
$event = "message"; $data = "";
foreach (explode("\n", $frame) as $line) {
if (strpos($line, "event:") === 0) $event = trim(substr($line, 6));
elseif (strpos($line, "data:") === 0) $data .= trim(substr($line, 5));
}
if ($data !== "") $handle($event, $data);
}
return strlen($chunk);
},
]);
if (curl_exec($ch) === false) throw new RuntimeException(curl_error($ch));
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if (strpos($ctype, "text/event-stream") === false) {
$env = json_decode($buf) ?? new stdClass();
if ($status < 200 || $status > 299) {
throw new ApiError($status, $env->error->code ?? null, $env->error->message ?? "HTTP $status");
}
$done = $env->data ?? null;
}
if ($failure) throw new RuntimeException(($failure->code ?? "") . ": " . ($failure->message ?? "") . " (job " . ($failure->job_id ?? "") . ")");
$full = $done->output->output ?? "";
if ($full === "") $full = $streamed;
echo "truncated: ", json_encode(($done->truncated ?? false) === true), ", charged: ", $done->charged_credits ?? "-", "\n";
$result = json_decode(substr($full, strpos($full, "{"), strrpos($full, "}") - strpos($full, "{") + 1));
echo $result->verdict, " - ", $result->headline, "\n";
foreach ($result->changes ?? [] as $c) echo "change ", implode(",", $c->flags ?? []), " ", $c->id ?? "", " ", $c->block ?? "", "\n";
foreach ($result->kept ?? [] as $k) echo "kept ", $k->flag, " - ", $k->reason ?? "", "\n";
foreach ($result->variables ?? [] as $v) echo "variable ", $v->name, " masked=", json_encode($v->masked ?? false), " protected=", json_encode($v->protected ?? false), "\n";
file_put_contents("rewritten.gitlab-ci.yml", $result->pipeline ?? "");
// the rewrite worked example: terraform-config.yml and terraform-facts.json from build-facts.js
// (for a rewrite of an audited paste, pass the audit_handoff string as the last argument)
var rwInput = MakeInput("rewrite", "terraform-config.yml", "terraform-facts.json",
"Please make this safe: apply should only happen after someone reads the plan, and the state password must not be in the repo.");
var rwDigest = System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(rwInput)));
var rwKey = "pushproof:rewrite:" + Convert.ToHexString(rwDigest, 0, 8).ToLowerInvariant() + ":a1";
using var sreq = Api.Request("POST", "/run-stream", rwInput, new Dictionary<string, string> { ["Idempotency-Key"] = rwKey });
using var sres = await Api.Http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
JsonNode? done = null, failure = null;
var streamed = new StringBuilder();
if (sres.Content.Headers.ContentType?.MediaType != "text/event-stream")
{
JsonNode? env = null;
try { env = JsonNode.Parse(await sres.Content.ReadAsStringAsync()); } catch (JsonException) { }
if (!sres.IsSuccessStatusCode) throw Api.ErrorFrom(sres, env);
done = env?["data"];
}
else
{
using var reader = new StreamReader(await sres.Content.ReadAsStreamAsync());
var ev = "message";
var data = new StringBuilder();
string? line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (line.Length == 0) // a blank line ends a frame
{
if (data.Length > 0)
{
JsonNode? p = null;
try { p = JsonNode.Parse(data.ToString()); } catch (JsonException) { }
if (p != null)
{
switch (ev)
{
case "delta": streamed.Append(p["text"]?.GetValue<string>() ?? ""); break;
case "job": Console.WriteLine($"job {p.ToJsonString()}"); break;
case "done": case "pending": done = p; break;
case "error": failure = p; break;
}
}
}
ev = "message";
data.Clear();
}
else if (line.StartsWith("event:")) ev = line[6..].Trim();
else if (line.StartsWith("data:")) data.Append(line[5..].Trim());
}
}
if (failure != null) throw new Exception($"{failure["code"]}: {failure["message"]} (job {failure["job_id"]})");
var full = done?["output"]?["output"]?.GetValue<string>();
if (string.IsNullOrEmpty(full)) full = streamed.ToString();
var cut = done?["truncated"] is JsonValue tv && tv.TryGetValue(out bool cutShort) && cutShort;
Console.WriteLine($"truncated: {cut}, charged: {done?["charged_credits"]}");
var result = JsonNode.Parse(full[full.IndexOf('{')..(full.LastIndexOf('}') + 1)])!;
Console.WriteLine($"{result["verdict"]} - {result["headline"]}");
foreach (var c in result["changes"]?.AsArray() ?? new JsonArray())
Console.WriteLine($"change {string.Join(",", (c?["flags"]?.AsArray() ?? new JsonArray()).Select(u => u?.ToString()))} {c?["id"]} {c?["block"]}");
foreach (var k in result["kept"]?.AsArray() ?? new JsonArray())
Console.WriteLine($"kept {k?["flag"]} - {k?["reason"]}");
foreach (var v in result["variables"]?.AsArray() ?? new JsonArray())
Console.WriteLine($"variable {v?["name"]} masked={v?["masked"]} protected={v?["protected"]}");
File.WriteAllText("rewritten.gitlab-ci.yml", result["pipeline"]?.GetValue<string>() ?? "");
What the page does that you now do
- Reads the paste in the browser and builds
configandfactsfrom it (glkit.js), cutting a large paste on whole blocks; you send the same two strings. - Prices every input with
/estimateand refuses to run belowmin_credits. - Sends each attempt with an input- and lane-derived
Idempotency-Key, and retries once withretry_notewhen a reply does not parse. - Closes a cut-off JSON reply and shows what arrived when
truncatedis true or the stream ends early. - Hands an audit reply to the rewrite lane as
audit_handoff, only while the paste is unchanged, and refuses a rewrite of a paste too large to send whole. - Checks the reply against the config (
recon.js): blocks, lines, evidence, fixes and the rewritten file re-run through the free check, flag answers, verdict, jobs, variables and repeated secrets.
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.