← Hermetic Build Audit / API
Tokens

Drive Hermetic Build Audit from your own code

The page reads one pasted Bazel workspace configuration in the browser for free — one or more files joined into one paste, each introduced by a # file: <path> line: .bazelversion, .bazelrc and other rc files, MODULE.bazel, WORKSPACE, the BUILD files that matter and the .bzl files they load — with its line numbers. It lexes Starlark strings and comments out first, reads every top-level call (rules, macros, load, package, module, bazel_dep, overrides, repository rules) with its attributes, reads every rc line with its command, config and flags (quotes and \ continuations included) and follows --config chains, so a flag that reaches every build through a config counts. Then it runs 21 deterministic checks drawn from Bazel build practice (catalogue ids BZ-01..BZ-21, plus BZ-22 for context): whether two machines given the same commit build the same bytes (the Bazel version, checksums, commits, absolute paths, network or the clock inside actions, the sandbox) and share cache keys (the action environment, who writes the remote cache, plaintext endpoints, stamping, test caching, targets that switch caching off), whether the graph is sound (glob() in deps, root targets over the whole tree, public-by-default packages), and whether the rc files do what they look like (undefined configs, credentials, WORKSPACE on Bazel 8, --jobs, timeouts, experimental flags). Nothing is fetched, analysed or built: every flag is a reading of the text. Its two paid steps are available over HTTP. You send one JSON object with task set to "audit" or "fix", the paste as code and the page’s check as a facts string, and the model replies with one JSON object: an audit that rates hermeticity, caching, the build graph and the configuration, answers every flag and gives exact before/after edits per finding, or the whole fixed file(s) with a list of every change tied to the page’s flags.

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. Both lanes run on the gpt-terra model with a 10% publisher markup. A run needs a signed-in (user) token and is metered: the platform places a hold on your credits when the run starts and then charges only what the run used. /estimate is free.

The task field: two lanes

task is the first field of every body. It routes the run, and it takes exactly two values: "audit" and "fix". 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 Bazelkit.buildInput turns any lane that is not "fix" into "audit"), so a typo silently gets you an audit: always send one of the two values.

taskWhat it doesverdictUse it when
"audit"Reviews the configuration the way a senior build engineer reviews a Bazel workspace before tuning CI: says what the workspace builds and how its dependencies and cache are set up (workspace), rates the four areas (hermetic, cache, graph, config) as sound, fragile or broken, confirms or dismisses every page flag, adds what text rules cannot see, and gives the verbatim evidence and exact before/after edits for each finding, listed in the order to apply them — what fails or leaks first (undefined configs, credentials, unpinned downloads, branch pins, absolute paths, network in actions), then what splits or poisons the cache, then what wastes time, then hygiene — with a plan that ends in two clean CI builds and a read of the second one’s cache hits.blocked | needs_work | readyBefore you tune CI or turn on a shared cache: does the build fail, drift between machines or leak a key, and what should change first.
"fix"Writes the fixed configuration: the whole of every pasted file it changes (never a new file), with every target, repository, bazel_dep and named config kept — plus a changes list tied to the page flags (each naming the target label it is in, or (file)), the verify steps (a clean build and test with the CI config, then the check that would have caught the worst problem), and what it left open for a fact or a decision only the engineer has (a checksum, a commit, an endpoint, whether a tag is still needed).fixed | partialWhen you want the files changed, not a list of findings: a configuration you already know is broken, or what is left after an audit.

The model never invents a checksum, a commit, a registry version, an endpoint or a label. When the note or the paste does not give one, the audit leaves the edit out and asks in questions, and the fix lane leaves the item open. It never copies a credential it finds in the paste.

The page chains the two: after an audit, “Apply every edit and fix the rest” puts every audit edit into the paste, switches to the fix lane on that edited paste and adds an audit_handoff string (below) with the audit’s verdict, headline and findings. Over the API that is a second run with task: "fix", the edited paste as code, facts recomputed from the edited paste, and that string. The page drops the handoff as soon as the paste is edited again.

Guards the page applies before a run and the API does not: it refuses to price or run a paste it cannot read (“Nothing was pasted.”, “No Bazel file was found. Paste .bazelrc, MODULE.bazel or WORKSPACE, .bazelversion and BUILD files, with a # file: <path> line above each.”, or “This does not read as Bazel: no rc line, no rule or macro call, no load() and no .bazelversion was found.”), and it warns before a run on a paste with no .bazelrc (“No .bazelrc was pasted: the cache, sandbox and config checks need it.”).

One worked example per lane follows, both examples the page ships (the example buttons on the app page; the full pastes are in /example.js, ids monorepo and vendor), built exactly as the page builds them: Bazelkit.buildInput(lane, Bazelkit.analyze(text), note, null), with the saved model reply for each. The other two examples are audits that reach the other verdicts: legacy (needs_work, 8 flags (5 medium, 3 low)) and ready (ready, 1 flag (1 low)); the monorepo audit also carries a saved fix run of its edited files (fixed). The request bodies cut the long code and facts strings with a visible ...; the replies are shown whole, pretty-printed, exactly as saved. The companies, hosts, keys and paths are invented.

Worked example, task: "audit": a Python monorepo whose CI config names a config that does not exist, with an API key in .bazelrc, the sandbox off, PATH leaking into cache keys and stamping on every build

5 files: .bazelversion, .bazelrc, MODULE.bazel, services/ingest/BUILD.bazel, libs/textkit/BUILD.bazel. The page’s free check raises 8 flags (2 high, 4 medium, 2 low) (plus the BZ-22 context flag); its own reading of the verdict is blocked. Running build-check.js on it (below) prints 5 file(s), 5 target(s), 3 repositor(ies), 14 rc line(s), 2 config(s); 8 flags (2 high, 4 medium, 2 low); verdict_candidate blocked. The engineer’s note: “Laptops and CI almost never share cache hits. CI runs bazel build //... --config=ci and must keep writing the remote cache; laptops should only read it. --spawn_strategy=local was added months ago while debugging one flaky test and nobody remembers why. The --stamp timestamp is only needed for release builds. What do we fix first?”

The request body (POST /run or /run-stream), idempotency key hermetic-build-audit:audit:1e712138-26f6:a1:

{
  "task": "audit",
  "code": "# file: .bazelversion\n7.4.1\n# file: .bazelrc\ncommon --enable_bzlmod\n\n# Everyone\nbuild --enable_platform_specific_config\nbuild --action_env=PATH\nbuild --spawn_strategy=local\nbuild --stamp\nbuild --workspace_status_command=tools/status.sh\nbuild --experimental_rem...",
  "facts": "{\"workspace\":{\"bazel_version\":\"7.4.1\",\"bazelversion_file\":\"7.4.1\",\"module\":{\"name\":\"quillpath\",\"version\":\"0.1.0\"},\"bzlmod\":true,\"workspace_deps\":false,\"files\":5,\"targets\":5,\"repositories\":0,\"bazel_deps\":3,\"rc_lines\":14,\"...",
  "note": "Laptops and CI almost never share cache hits. CI runs bazel build //... --config=ci and must keep writing the remote cache; laptops should only read it. --spawn_strategy=local was added months ago while debugging one flaky test and nobody remembers why. The --stamp timestamp is only needed for release builds. What do we fix first?"
}

The saved reply (output.output), verdict blocked, 8 findings, 6 edits:

{
  "task": "audit",
  "verdict": "blocked",
  "headline": "Two high findings - an undefined --config and a committed cache credential - block every CI build outright, and several medium cache-key issues explain why laptops and CI still don't share hits.",
  "workspace": "The quillpath workspace builds two Bazel-native Python packages (services/ingest and libs/textkit) via Bzlmod, with three pinned bazel_dep registry versions and no WORKSPACE dependencies, and reads/writes a grpcs remote cache split between an always-on build:remote_cache config and a build:ci override.",
  "areas": [
    {
      "area": "hermetic",
      "status": "fragile",
      "note": "The version is pinned and every declared dependency uses a registry version, but the global spawn strategy override lets actions build outside the sandbox."
    },
    {
      "area": "cache",
      "status": "fragile",
      "note": "A leaking PATH in the action environment, no global stop on non-CI uploads, and stamping on every build together explain why laptops and CI rarely share a hit."
    },
    {
      "area": "graph",
      "status": "sound",
      "note": "Both packages use fine-grained, labelled targets with deliberate visibility; nothing here globs deps or grants blanket public access."
    },
    {
      "area": "config",
      "status": "broken",
      "note": "build:ci points at an undefined --config and the shared remote cache config carries a live credential in the checked-in file."
    }
  ],
  "findings": [
    {
      "id": "BZ-16",
      "flags": [
        "F2"
      ],
      "file": ".bazelrc",
      "line": 20,
      "severity": "high",
      "area": "config",
      "title": "build:ci chains to an undefined --config",
      "evidence": "build:ci --config=remote-cache",
      "why": "Any command that reaches this line - including CI's own bazel build //... --config=ci - fails with \"Config value 'remote-cache' is not defined in any .rc file\"; remote_cache (underscore) is defined four lines above and is almost certainly the intended target.",
      "edits": [
        {
          "before": "build:ci --config=remote-cache",
          "after": "build:ci --config=remote_cache"
        }
      ]
    },
    {
      "id": "BZ-18",
      "flags": [
        "F1"
      ],
      "file": ".bazelrc",
      "line": 15,
      "severity": "high",
      "area": "config",
      "title": "Remote cache header carries a live credential",
      "evidence": "build:remote_cache --remote_cache=grpcs://cache.quillpath.example:443",
      "why": "The line just below this one sets --remote_header to a literal x-api-key value (not quoted here); anyone who can read the repository can call the cache service as this workspace. Rotate that value and move it out of the checked-in file - a CI secret passed on the command line, a git-ignored user.bazelrc read via try-import, or --credential_helper.",
      "edits": []
    },
    {
      "id": "BZ-07",
      "flags": [
        "F3",
        "F6"
      ],
      "file": ".bazelrc",
      "line": 8,
      "severity": "medium",
      "area": "cache",
      "title": "Action environment leaks the client's PATH into cache keys",
      "evidence": "build --action_env=PATH",
      "why": "A bare --action_env=PATH copies whichever PATH the person or machine running Bazel has into every action's cache key, so a laptop and CI compute different keys for the same action and never share a hit; --incompatible_strict_action_env fixes PATH and every other inherited variable at once, which is what a remote cache needs to be worth having.",
      "edits": [
        {
          "before": "build --action_env=PATH",
          "after": "build --incompatible_strict_action_env"
        }
      ]
    },
    {
      "id": "BZ-08",
      "flags": [],
      "file": ".bazelrc",
      "line": 15,
      "severity": "medium",
      "area": "cache",
      "title": "Nothing stops a laptop from writing the shared cache",
      "evidence": "build:remote_cache --remote_cache=grpcs://cache.quillpath.example:443",
      "why": "Only build:ci sets --remote_upload_local_results=true; there is no global --noremote_upload_local_results, so any machine that turns on the remote_cache config also uploads. Per the note, CI must keep writing the cache and laptops should only read it - a global --noremote_upload_local_results alongside the existing build:ci override gives exactly that split.",
      "edits": [
        {
          "before": "# Shared cache\nbuild:remote_cache --remote_cache=grpcs://cache.quillpath.example:443",
          "after": "# Shared cache\nbuild --noremote_upload_local_results\nbuild:remote_cache --remote_cache=grpcs://cache.quillpath.example:443"
        }
      ]
    },
    {
      "id": "BZ-10",
      "flags": [
        "F5"
      ],
      "file": ".bazelrc",
      "line": 10,
      "severity": "medium",
      "area": "cache",
      "title": "Stamping runs on every build, not just releases",
      "evidence": "build --stamp",
      "why": "A stamped action embeds the commit and build time, so its output - and everything that links it - changes on every commit and never hits the cache. Per the note the timestamp is only needed for release builds, so --stamp belongs in a new build:release config; release builds add --config=release.",
      "edits": [
        {
          "before": "build --stamp",
          "after": "build:release --stamp"
        }
      ]
    },
    {
      "id": "BZ-06",
      "flags": [
        "F4"
      ],
      "file": ".bazelrc",
      "line": 9,
      "severity": "medium",
      "area": "hermetic",
      "title": "Sandbox switched off for every action",
      "evidence": "build --spawn_strategy=local",
      "why": "Running every action outside the sandbox lets it read undeclared files or the host's own tools, so it can build different bytes on a machine whose sandbox stays on and poison the shared cache. Per the note nobody remembers which action needed this months-old override, so the safe fix is to drop the global flag; if one action still needs it, give that action a tag or execution_requirements instead of every build.",
      "edits": [
        {
          "before": "build --spawn_strategy=local\nbuild:release --stamp",
          "after": "build:release --stamp"
        }
      ]
    },
    {
      "id": "BZ-21",
      "flags": [
        "F7"
      ],
      "file": ".bazelrc",
      "line": 12,
      "severity": "low",
      "area": "config",
      "title": "Experimental flag on every build",
      "evidence": "build --experimental_remote_cache_compression",
      "why": "Experimental flags can change meaning or vanish in any release, and an unrecognized one stops every command outright; whether this one is still current on Bazel 7.4.1 needs a check against that release's docs, which the paste doesn't show.",
      "edits": []
    },
    {
      "id": "BZ-20",
      "flags": [
        "F8"
      ],
      "file": ".bazelrc",
      "line": 17,
      "severity": "low",
      "area": "config",
      "title": "Remote timeout set to an hour",
      "evidence": "build:remote_cache --remote_timeout=3600",
      "why": "At 3600 seconds, a single hung remote-cache call can block for an hour before Bazel falls back to building locally. The default of 60 seconds is usually right; this raises it a little for slow uploads without leaving a bad cache host able to stall a build for an hour.",
      "edits": [
        {
          "before": "build:remote_cache --remote_timeout=3600",
          "after": "build:remote_cache --remote_timeout=120"
        }
      ]
    }
  ],
  "flag_answers": [
    {
      "flag": "F1",
      "status": "confirmed",
      "reason": "carried by the BZ-18 finding"
    },
    {
      "flag": "F2",
      "status": "confirmed",
      "reason": "carried by the BZ-16 finding"
    },
    {
      "flag": "F3",
      "status": "confirmed",
      "reason": "carried by the BZ-07 finding"
    },
    {
      "flag": "F4",
      "status": "confirmed",
      "reason": "carried by the BZ-06 finding"
    },
    {
      "flag": "F5",
      "status": "confirmed",
      "reason": "carried by the BZ-10 finding"
    },
    {
      "flag": "F6",
      "status": "confirmed",
      "reason": "carried by the same BZ-07 finding as F3"
    },
    {
      "flag": "F7",
      "status": "confirmed",
      "reason": "carried by the BZ-21 finding"
    },
    {
      "flag": "F8",
      "status": "confirmed",
      "reason": "carried by the BZ-20 finding"
    }
  ],
  "plan": [
    "Fix the --config=remote-cache typo on build:ci to --config=remote_cache so bazel build //... --config=ci stops failing outright.",
    "Rotate the x-api-key value on the remote_header line and move it out of .bazelrc - a CI secret on the command line, a git-ignored user.bazelrc via try-import, or --credential_helper.",
    "Replace build --action_env=PATH with build --incompatible_strict_action_env so every machine computes the same action keys.",
    "Add a global build --noremote_upload_local_results so only build:ci --remote_upload_local_results=true writes the shared cache.",
    "Move --stamp into a new build:release config so only release builds pay the never-cached cost.",
    "Drop the global --spawn_strategy=local so actions run sandboxed again; give the one flaky-test action a targeted tag instead if it still needs it.",
    "Confirm whether --experimental_remote_cache_compression is still valid on Bazel 7.4.1 and drop it if it has been promoted or removed.",
    "Lower --remote_timeout from 3600 toward the 60s default.",
    "Verify: run bazel build //... --config=ci twice on a clean CI runner and read the second run's cache hits (bazel build --profile=profile.gz then bazel analyze-profile profile.gz, or the Build Event Protocol), and run bazel aquery on one target on a laptop and on CI to compare action keys."
  ],
  "questions": [
    "Does any action still need --spawn_strategy=local (the flaky test nobody remembers), and if so which target, so it can get a tag instead of the global override?",
    "Is --experimental_remote_cache_compression still experimental on Bazel 7.4.1, or has it been promoted or removed?",
    "libs/textkit/BUILD.bazel globs srcs as textkit/*.py from inside libs/textkit/ itself - if the .py files live directly in that directory rather than a nested libs/textkit/textkit/ folder, the glob matches nothing and srcs is silently empty; is that pattern intentional?",
    "Should build --workspace_status_command=tools/status.sh move into build:release alongside --stamp, or does something else on every build consume its output?"
  ],
  "summary": "Fix the two high findings first: the build:ci config typo (remote-cache vs remote_cache) that fails every --config=ci build, and the committed x-api-key, which must be rotated and moved off disk. Then close the medium cache-key issues - the leaking PATH, the missing upload guard, and universal stamping - which is why laptops and CI almost never share a hit; --spawn_strategy=local looks safe to drop now that no one can say which action still needs it. The experimental flag and the hour-long timeout are hygiene, not blockers."
}

The credential finding carries no edit: it cannot be fixed without quoting the value, so the fix lane rewrites the file without it. The page applies the other edits in order and re-checks: 6 edit(s) applied, 0 did not; with every edit in place the free check raises 1 high, 0 medium and 1 low flag(s).

Worked example, task: "fix": a map-rendering workspace with an absolute local_path_override, a placeholder sha256, a git repository on a branch, curl inside a genrule, a plaintext cache and every test run three times

4 files: .bazelversion, .bazelrc, MODULE.bazel, tools/fonts/BUILD.bazel. The free check raises 7 flags (4 high, 3 medium); build-check.js prints 4 file(s), 2 target(s), 6 repositor(ies), 6 rc line(s), 1 config(s); 7 flags (4 high, 3 medium, 0 low); verdict_candidate blocked. The engineer’s note: “Fix what you can. geo_math is vendored at third_party/geo-math in this repository now. The tested commit of shape_codec is 3f9c2e1d4b6a8c0e2f4a6b8d0c2e4f6a8b0d2c4e. The cache also serves grpcs on bazel-cache.corp.lan:9092. We do not have the tzdata sha256 yet and the fonts zip has no published checksum - leave those two for us. The three runs per test were only meant for the geometry tests we suspect are flaky.”

The request body, idempotency key hermetic-build-audit:fix:212354fb-1d96:a1:

{
  "task": "fix",
  "code": "# file: .bazelversion\n7.4.1\n# file: .bazelrc\ncommon --enable_bzlmod\nbuild --incompatible_strict_action_env\n\nbuild:ci --remote_cache=http://bazel-cache.corp.lan:8080\nbuild:ci --remote_upload_local_results\n\ntest --runs_per_test=3\ntest --test_output=errors\n# file...",
  "facts": "{\"workspace\":{\"bazel_version\":\"7.4.1\",\"bazelversion_file\":\"7.4.1\",\"module\":{\"name\":\"orbrinth_maps\",\"version\":\"1.8.0\"},\"bzlmod\":true,\"workspace_deps\":false,\"files\":4,\"targets\":2,\"repositories\":3,\"bazel_deps\":3,\"rc_lines\":...",
  "note": "Fix what you can. geo_math is vendored at third_party/geo-math in this repository now. The tested commit of shape_codec is 3f9c2e1d4b6a8c0e2f4a6b8d0c2e4f6a8b0d2c4e. The cache also serves grpcs on bazel-cache.corp.lan:9092. We do not have the tzdata sha256 yet and the fonts zip has no published checksum - leave those two for us. The three runs per test were only meant for the geometry tests we suspect are flaky."
}

The saved reply, verdict partial, 2 file(s), 4 change(s), 2 open item(s):

{
  "task": "fix",
  "verdict": "partial",
  "headline": "Vendored geo_math to a workspace-relative path, pinned shape_codec to its tested commit, moved the CI remote cache to grpcs, and scoped the 3x test reruns into a test:flaky config; the tzdata checksum and the font-fetch genrule (with its no-sandbox tag) stay open pending checksums you don't have yet.",
  "files": [
    {
      "path": ".bazelrc",
      "content": "common --enable_bzlmod\nbuild --incompatible_strict_action_env\n\nbuild:ci --remote_cache=grpcs://bazel-cache.corp.lan:9092\nbuild:ci --remote_upload_local_results\n\ntest:flaky --runs_per_test=3\ntest --test_output=errors\n"
    },
    {
      "path": "MODULE.bazel",
      "content": "module(name = \"orbrinth_maps\", version = \"1.8.0\")\n\nbazel_dep(name = \"platforms\", version = \"0.0.10\")\nbazel_dep(name = \"rules_cc\", version = \"0.0.16\")\nbazel_dep(name = \"geo_math\", version = \"0.3.0\")\nlocal_path_override(\n    module_name = \"geo_math\",\n    path = \"third_party/geo-math\",\n)\n\nhttp_archive = use_repo_rule(\"@bazel_tools//tools/build_defs/repo:http.bzl\", \"http_archive\")\ngit_repository = use_repo_rule(\"@bazel_tools//tools/build_defs/repo:git.bzl\", \"git_repository\")\n\nhttp_archive(\n    name = \"tzdata_bundle\",\n    build_file = \"//third_party:tzdata.BUILD\",\n    sha256 = \"...\",\n    strip_prefix = \"tzdata-2024b\",\n    urls = [\"https://data.iana.org/time-zones/releases/tzdata2024b.tar.gz\"],\n)\n\ngit_repository(\n    name = \"shape_codec\",\n    remote = \"https://github.com/orbrinth-maps/shape-codec.git\",\n    commit = \"3f9c2e1d4b6a8c0e2f4a6b8d0c2e4f6a8b0d2c4e\",\n)\n"
    }
  ],
  "changes": [
    {
      "file": "MODULE.bazel",
      "target": "(file)",
      "flags": [
        "F1"
      ],
      "change": "Changed local_path_override(geo_math)'s path from the absolute /home/dana/src/geo-math to the vendored third_party/geo-math.",
      "why": "Every machine now resolves geo_math from inside the checked-out workspace instead of a directory that only exists on one developer's machine."
    },
    {
      "file": "MODULE.bazel",
      "target": "(file)",
      "flags": [
        "F3"
      ],
      "change": "Replaced git_repository(shape_codec)'s branch = \"main\" with commit = \"3f9c2e1d4b6a8c0e2f4a6b8d0c2e4f6a8b0d2c4e\".",
      "why": "Every fetch now resolves the exact tested revision instead of whatever main points to at fetch time, so two machines build the same code under the same cache key."
    },
    {
      "file": ".bazelrc",
      "target": "(file)",
      "flags": [
        "F5"
      ],
      "change": "Changed build:ci --remote_cache from http://bazel-cache.corp.lan:8080 to grpcs://bazel-cache.corp.lan:9092.",
      "why": "Cache blobs and headers now travel encrypted instead of being readable or spoofable by anyone on the path."
    },
    {
      "file": ".bazelrc",
      "target": "(file)",
      "flags": [
        "F6"
      ],
      "change": "Moved --runs_per_test=3 off the shared test command into a new test:flaky config.",
      "why": "The extra runs were only meant for the suspected-flaky geometry tests, so every other test stops paying the 3x cost; run the geometry tests with --config=flaky until they carry flaky = True directly."
    }
  ],
  "verify": [
    "bazel build //... --config=ci and bazel test //... --config=ci on a clean checkout, including a machine that never had /home/dana/src/geo-math, to confirm geo_math still resolves via third_party/geo-math.",
    "bazel fetch --config=ci //... then bazel aquery on a target depending on @shape_codec to confirm the fetched revision is 3f9c2e1d4b6a8c0e2f4a6b8d0c2e4f6a8b0d2c4e on every machine, not whatever main currently points to.",
    "Run bazel build //... --config=ci twice on a clean CI runner and check the Build Event Protocol (or bazel build --profile=profile.gz then bazel analyze-profile profile.gz) for remote cache hits over grpcs://bazel-cache.corp.lan:9092 on the second run.",
    "bazel test //path/to:geometry_tests --config=flaky to confirm the 3x reruns still apply there, and bazel test //... --config=ci to confirm the rest of the suite now runs each test once."
  ],
  "open": [
    {
      "item": "http_archive(tzdata_bundle) still has the placeholder sha256 = \"...\"",
      "flags": [
        "F2"
      ],
      "reason": "You said you don't have the real tzdata sha256 yet; fetch the archive once and paste its sha256 (or integrity) here before this can be pinned."
    },
    {
      "item": "genrule //tools/fonts:inter_fonts still curls the Inter fonts zip at build time and keeps the no-sandbox tag",
      "flags": [
        "F4",
        "F7"
      ],
      "reason": "You said the fonts zip has no published checksum yet, so the curl can't become a checksummed http_file/http_archive dependency; until it does, the action still needs network access and no-sandbox has to stay or a sandboxed build would fail trying to reach the network. Once you have a checksum, declare it as a pinned repository (via use_repo_rule) and depend on its label, then drop the tag."
    }
  ],
  "questions": [],
  "summary": "Fixed the four flags the note gave you facts for: geo_math now points at the vendored third_party/geo-math, shape_codec is pinned to the tested commit, the CI remote cache uses the grpcs endpoint you gave, and the 3x test reruns moved into a test:flaky config instead of applying to every test. The tzdata checksum and the fonts-zip fetch (and its no-sandbox tag) are left open because you said those two checksums aren't available yet; nothing else in the paste was touched."
}

The fixes use only what the note gives (the vendored path, the tested commit, the grpcs port); the two checksums the note says are not known yet stay open, so the verdict is partial.

The response envelope

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

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

Error codes

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

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

Failures that do not arrive as an HTTP status:

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

The input fields

Every field is a string. The app declares this input schema; task, code and facts are required.

FieldTypeWhat it is
taskstring, required"audit" or "fix" (above).
codestring, requiredThe paste, verbatim: every file introduced by a # file: <path> line. Line 1 is its first line; every line number in facts and in the reply is a line of this string.
factsstring, requiredThe page’s analysis as a JSON string (below).
audit_handoffstring, optionalFix lane only: an audit handed on, as a JSON string (below).
notestring, optionalWhat the engineer says, cut to 2,000 characters: a goal, a constraint, facts the files cannot show (a tested commit, a TLS port). Context, never instructions, and never evidence about the files.
retry_notestring, optionalSent only on the page’s one automatic retry, when the first reply did not parse.

The code string

Paste the files one after another, each preceded by # file: <path> with the path from the workspace root (# file: .bazelrc, # file: MODULE.bazel, # file: apps/web/BUILD.bazel). A marker’s path must look like a path (a dot or a slash) or be a Bazel file name (BUILD, WORKSPACE, MODULE.bazel ...); a line --- directly before a marker only separates files. A paste with no marker is one file, named by what it looks like. A target’s package is the directory of its BUILD file. The page reads up to 400,000 characters and 60 files, and sends at most 60,000 characters: over that it drops whole files from the end (files it does not check first, then .bzl, then BUILD files) and ends code with a line # [hermetic-build-audit: not sent: ...] naming them, and says so in facts.notes. The model is told never to quote what it was not sent. facts is bounded too, to about 40,000 characters: long entries are clipped, the flag list keeps at most 150 flags (the most severe first, flags in sent files before the rest), the other lists give way first, and every cut is named in facts.notes. An audit_handoff over 30,000 characters is cut the same way.

The facts string

facts is produced by the page’s free check: Bazelkit.analyze splits the paste into files, reads the rc lines and the Starlark calls and runs the rules, and Bazelkit.facts(A) turns that analysis into the object below (it does not depend on the lane). It is how the model knows which flags to answer, which paths and line ranges to name, and which targets, repositories and configs must survive. Its top-level keys, in the order built:

KeyContents
workspacebazel_version (the exact release in a pasted .bazelversion, or null), bazelversion_file (its raw value, or null when none was pasted), module (name and version from module(), or null), bzlmod (a MODULE.bazel was pasted), workspace_deps (a WORKSPACE declares more than workspace()), and counts: files, targets, repositories, bazel_deps, rc_lines, configs.
filesEvery file: path (use it verbatim), kind (version, rc, module, workspace, build, bzl, ignore, other), line and end_line (the paste lines of the file’s content, without its # file: line).
targetsEvery target (up to 300): label (//pkg:name), kind (the rule or macro), file, line, and tags when it has any.
repositoriesEvery repository rule and override (up to 120): kind, name, file, line, pinned (checksum, placeholder, none, commit, tag, branch, path).
bazel_depsEvery bazel_dep (up to 150): name, version, dev, file, line.
rcEvery rc line (up to 200): file, line, command, config, flags (as written; a credential value shows as ***).
configs_defined, configs_referencedThe named configs rc lines define (build:ci ...), and every value passed to --config.
flagsEvery flag the page raised: uid (F1, F2, ... in severity, file, then line order), id (BZ-01..BZ-22), sev (high | medium | low | info), file, line, text, detail, and lines (every line an aggregated flag covers, when there are several).
verdict_candidateThe audit verdict the flags point at: blocked with any high flag, needs_work with any medium, otherwise ready.
notesPlain sentences about what was not read, not sent or cut to fit (empty for a paste that fits).

The catalogue:

IdFlagArea
BZ-01Bazel version not pinned to an exact releasehermetic
BZ-02download with no checksum, or a placeholder checksumhermetic
BZ-03git dependency pinned to a branch, or not pinnedhermetic
BZ-04local repository at an absolute path on one machinehermetic
BZ-05network access or the clock inside a build actionhermetic
BZ-06sandboxing switched offhermetic
BZ-07action environment leaks into cache keyscache
BZ-08every machine writes the shared remote cachecache
BZ-09remote cache or executor over a plaintext connectioncache
BZ-10stamping on every buildcache
BZ-11test results never cached, or every test run several timescache
BZ-12caching switched off on a targetcache
BZ-13glob() in depsgraph
BZ-14one coarse target over the whole treegraph
BZ-15every target public by defaultgraph
BZ-16--config that no rc line definesconfig
BZ-17external dependencies still in WORKSPACEconfig
BZ-18credential literal in the build configurationconfig
BZ-19--jobs far beyond one machine with no remote executorconfig
BZ-20remote timeout of ten minutes or moreconfig
BZ-21experimental flag in the shared configurationconfig
BZ-22contextcontext

The simplest source is bazelkit.js itself: the page loads it from /bazelkit.js, and it also runs in Node (it sets module.exports). This script prints what the page would send as code and facts (with the 60,000-character cut applied), with the facts decoded so you can read them; the step 4 samples compact them back into the page’s string byte for byte. Save the paste as monorepo-paste.txt and redirect the output into monorepo-check.json:

// build-check.js -- run: node build-check.js monorepo-paste.txt > monorepo-check.json   (bazelkit.js saved next to it)
const fs = require("fs");
const Bazelkit = require("./bazelkit.js");
const A = Bazelkit.analyze(fs.readFileSync(process.argv[2], "utf8"));
if (!A.ok) { console.error(A.errors.join("\n")); process.exit(1); }
const inp = Bazelkit.buildInput("audit", A, "", null);       // code and facts do not depend on the lane
console.log(JSON.stringify({ code: inp.code, facts: JSON.parse(inp.facts) }, null, 2));
const real = A.flags.filter((f) => f.sev !== "info");
console.error(A.files.length + " file(s), " + A.targets.length + " target(s), " + (A.repos.length + A.deps.length) + " repositor(ies), " +
  A.rc.length + " rc line(s), " + A.configs.length + " config(s); " + real.length + " flags (" + A.counts.high + " high, " +
  A.counts.medium + " medium, " + A.counts.low + " low); verdict_candidate " + A.candidate);

For the monorepo example it reports 5 file(s), 5 target(s), 3 repositor(ies), 14 rc line(s), 2 config(s); 8 flags (2 high, 4 medium, 2 low); verdict_candidate blocked; for the vendor example, 4 file(s), 2 target(s), 6 repositor(ies), 6 rc line(s), 1 config(s); 7 flags (4 high, 3 medium, 0 low); verdict_candidate blocked. For both worked examples its code and facts are exactly what the page sends.

Without the page’s check. The platform only checks that facts is a string, so "{}" works and the model still reads code. But you lose what the check adds: with no flags there is nothing to confirm, dismiss or close, with no files there are no paths and line ranges to name, with no bazel_version the model has to hedge every version-dependent edit (WORKSPACE on Bazel 8), and with no targets, repositories and configs_defined nothing says what must survive a fix. Build it with bazelkit.js when you can.

The audit_handoff string

Only for task: "fix", and only for the files the audit was about with every audit edit applied. The page builds both from the normalised audit reply res (recon.js normalize) and the analysis A of the audited paste, in recon.js fixHandoff:

const run = Recon.applyFindings(A, res.findings, Bazelkit);   // every finding's edits, in order, each on top of the last
JSON.stringify({ verdict: res.verdict, headline: res.headline,
  findings: res.findings.map(f => ({ id: f.id, severity: f.severity, file: f.file, line: f.line, title: f.title, why: f.why, edits: f.edits.length })),
  applied: /* edits that applied */, failed: /* edits that did not */ })
// run.A.text is the edited paste: it becomes code, and facts is recomputed from it

Each finding’s edits there is the number of edits it carried. applyFindings applies every finding’s edits with Bazelkit.applyEdits, in the order listed, to the finding’s file: each before must occur exactly once in that file as the earlier edits left it (blank lines at either end are tolerated, and a blank line written inside after is kept), or that edit is reported as failed and skipped — it is never guessed. This script does the whole step, in the same shape as build-check.js prints:

// handoff.js -- run: node handoff.js monorepo-paste.txt monorepo-reply.txt > monorepo-fix-check.json   (bazelkit.js, recon.js next to it)
const fs = require("fs");
const Bazelkit = require("./bazelkit.js");
const Recon = require("./recon.js");
const A = Bazelkit.analyze(fs.readFileSync(process.argv[2], "utf8"));          // the audited paste
const res = Recon.normalize(Recon.parseResult(fs.readFileSync(process.argv[3], "utf8")), "audit");
const ho = Recon.fixHandoff(A, res);                                            // every edit, in order
const B = Bazelkit.analyze(ho.text);                                            // facts recomputed from the edited paste
const inp = Bazelkit.buildInput("fix", B, "", ho.handoff);
console.log(JSON.stringify({ code: inp.code, facts: JSON.parse(inp.facts), audit_handoff: inp.audit_handoff }, null, 2));
console.error(ho.applied + " edit(s) applied, " + ho.failed + " did not apply");

For the monorepo audit above it reports 6 edit(s) applied, 0 did not apply; the edited paste raises 1 high, 0 medium and 1 low flag(s), and the page sends that fix run with the audit’s note and the key hermetic-build-audit:fix:560de5de-2db6:a1. Its saved reply is fixed (1 file(s), 1 change(s), 0 open item(s)).

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 grounds everything in code: unpasted BUILD and .bzl files, the remote cache, the CI runner and the machines are outside the paste, so they are asked about, never stated as facts. It never invents a checksum, a commit, a version, a URL, a host, a port or a label; it never writes a secret value (a line that holds a credential is never copied into the reply — the value moves to a CI secret, a git-ignored user.bazelrc read with try-import, or --credential_helper); and it keeps the build’s meaning: which targets exist, what they depend on, which repositories and named configs exist. Version-dependent advice follows facts.workspace.bazel_version. Every page flag whose sev is not info is answered exactly once (audit lane: in flag_answers), and an info flag is never a finding.

task: "audit"

task: "fix"

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 files array for fix, a findings array for audit). An unknown verdict shows as needs_work or partial and counts as a disagreement; an unknown severity becomes medium; ids like BZ7 become BZ-07; flags keeps only F<n> values; workspace may also arrive as job, file as path, a change’s target as label or function and its change as what; questions is capped at 10, plan at 30 and verify at 20. An audit reply carries 9 sections (verdict, headline, workspace, areas, findings, flag_answers, plan, questions, summary) and a fix reply 8 (verdict, headline, files, changes, verify, open, questions, summary), which is what the page counts when it recovers a partial reply.

The page checks the reply against the files; 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 code and the handoff it sent): it applies every edit or returned file to the paste and re-runs its own checks on the result. None of that happens in the API.

recon.js also runs in Node, so you can hold a reply to exactly the page’s standard. Save /bazelkit.js and /recon.js next to this script:

// recheck.js -- run: node recheck.js monorepo-check.json monorepo-reply.txt audit > monorepo-edited.txt   (bazelkit.js, recon.js next to it)
const fs = require("fs");
const Recon = require("./recon.js");                                            // loads ./bazelkit.js itself
const sent = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));             // code, facts (and audit_handoff) you sent
const lane = process.argv[4] || "audit";
const res = Recon.normalize(Recon.parseResult(fs.readFileSync(process.argv[3], "utf8")), lane);  // output.output
const r = Recon.reconcile(res, { facts: sent.facts, code: sent.code, handoff: sent.audit_handoff || null });
r.notes.forEach((n) => console.error(n.kind + ": " + n.text));
console.error(r.disagreements + " disagreement(s) in " + r.statements + " statements checked");
if (r.after) (r.after.flags || []).forEach((f) => console.error("still raised: " + f.id + " " + f.sev + " " + f.file + ":" + f.line + " " + f.text));
if (r.after && r.after.text) console.log(r.after.text.replace(/\n$/, ""));      // the paste with every edit (or file) put back

For the monorepo audit reply it reports:

ok: The areas table rates hermeticity, caching, the build graph and the configuration once each.
ok: Every one of the 8 findings names a pasted file, a line inside it and evidence copied from it.
ok: The verdict (blocked) fits the worst finding.
ok: Every one of the page's 8 flags is answered once.
ok: All 6 edits applied in order, each before text found exactly once.
ok: Every confirmed flag a finding carries is gone once all edits are applied.
ok: Every target, repository and named config of the paste is still there after the edits (5 targets, 3 repositories, 2 configs).
ok: The reply repeats no credential from the paste (1 found in it).
0 disagreement(s) in 8 statements checked
still raised: BZ-18 high .bazelrc:16 --remote_header carries a literal x-api-key value
still raised: BZ-21 low .bazelrc:11 1 experimental flag on every build (--experimental_remote_cache_compression)

and for the vendor fix reply (node recheck.js vendor-check.json vendor-reply.txt fix):

ok: 2 pasted files returned whole.
ok: With the fixed files in place the free check raises no high or medium flag beyond the ones left open (0 low).
info: F2, F4, F7 left open by the reply.
ok: Every target, repository and named config is still there (2 targets, 5 repositories, 1 config).
ok: Every one of the 4 changes names a returned file, a target in the fixed files (or the file itself) and real page flags.
ok: The verdict (partial) fits the open list.
ok: The reply repeats no credential from the paste.
0 disagreement(s) in 6 statements checked
still raised: BZ-02 high MODULE.bazel:26 http_archive tzdata_bundle has a placeholder sha256 "..."
still raised: BZ-05 high tools/fonts/BUILD.bazel:43 genrule //tools/fonts:inter_fonts runs curl inside the action
still raised: BZ-06 medium tools/fonts/BUILD.bazel:44 1 target tagged no-sandbox or local (:inter_fonts)

The page does not run Bazel. After that, build and test with the CI config twice on a clean runner, read the second run’s cache hits, and compare one action’s key on two machines, plus whatever the reply’s plan or verify names.

Step 1 — a tiny client helper

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

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

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

Step 2 — a token

Two kinds work:

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

Step 3 — GET /me

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

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

Step 4 — build the input, then POST /estimate

First a small makeInput that does what buildInput does: task, then code as the paste the page would send, facts as a compact JSON string, the note when it is not blank, and — for the fix lane only — the audit_handoff string when the file carries one. It reads the file build-check.js (or handoff.js) printed. The samples build the audit input of the monorepo example from monorepo-check.json; step 6 reuses makeInput for the vendor (fix) example. Then price it. The body of /estimate is the same input you will send to /run; no job is created and nothing is charged — /estimate is free. From the response the page reads:

FieldMeaning
hold_creditsA reservation, not a price: the worst case the platform holds while the run is in flight. The page shows it as “reserves N credits ($...) for the audit run” and adds that you are charged only for what the run actually uses, usually far less. What you pay is the job’s charged_credits after settlement (step 5). A balance below the hold is allowed, but the page warns that the reply may be cut short (truncated: true).
min_creditsThe smallest balance that can start a run. Below it the page disables the run button and names the shortfall, because the call would be a 402.
sponsor_enabledWhen true, a caller who is not signed in may run; otherwise the page asks them to sign in.
warningsInput warnings. They do not refuse the call; the page shows them next to the price (“input warnings: ...”), 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 code and facts, so read them from the call rather than hard-coding them.

# monorepo-check.json: printed by build-check.js (see "The facts string").
# (.facts | tojson) turns the facts into ONE compact string, as JSON.stringify does.
BODY=$(jq -c --arg note "Laptops and CI almost never share cache hits. CI runs bazel build //... --config=ci and must keep writing the remote cache; laptops should only read it. --spawn_strategy=local was added months ago while debugging one flaky test and nobody remembers why. The --stamp timestamp is only needed for release builds. What do we fix first?" \
  '{task: "audit", code, facts: (.facts | tojson), note: $note}' monorepo-check.json)
# No page check at hand? facts: "{}" is accepted as a string, but the reply then has no page flags to answer.
# Fix lane after an audit: read handoff.js's output instead and add its string:
#   '{task: "fix", code, facts: (.facts | tojson), note: $note} + (if .audit_handoff then {audit_handoff} else {} end)'

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

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

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

Idempotency key. Resending a request with the same key must not start a second paid run, so derive the key from the input, not from the clock — and include the lane, so an audit and a fix run of the same paste never share a key. The page uses hermetic-build-audit:<lane>:<inputHash>:a<attempt> (app.js run), where <lane> is audit or fix, <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 hermetic-build-audit:audit:1e712138-26f6:a1 (monorepo audit) and hermetic-build-audit:fix:212354fb-1d96:a1 (vendor fix); the fix run of the monorepo audit handed on (below) is hermetic-build-audit:fix:560de5de-2db6: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="hermetic-build-audit: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, areas: [(.areas // [])[] | {area, status}],
         findings: [(.findings // [])[] | {id, file, line, severity, edits: ((.edits // []) | length)}],
         flag_answers: [(.flag_answers // [])[] | {flag, status}]}'

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

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 fix input of the vendor example (with its own key) and stream it; to stream the audit input instead, pass the step 4 input and key. The response is text/event-stream. sdk.js splits it into frames on a blank line, takes the event name from an event: line (default message) and concatenates the data: lines, skips frames with no data or with data that is not JSON, and handles these events:

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

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

From the final payload the page reads output.output (falling back to the concatenated delta text), truncated and charged_credits. Browsers receive progress ticks rather than deltas from this endpoint, so the page relies on the final payload; if the stream fails part-way, it tries to recover a partial reply from whatever delta text arrived. The samples print the verdict and headline, each returned file’s path and size, each change (target, flags and change), each verification step, each open item and each question. To put the files into the paste and re-check them the way the page’s “Put every file into the paste” does, keep the reply text and run recheck.js with the lane fix. To fix code you audited, read handoff.js’s output instead of vendor-check.json: makeInput then carries its audit_handoff.

# the fix worked example: vendor-check.json from build-check.js
# (for audited code, read handoff.js's output instead; the + (...) adds its audit_handoff)
FBODY=$(jq -c --arg note "Fix what you can. geo_math is vendored at third_party/geo-math in this repository now. The tested commit of shape_codec is 3f9c2e1d4b6a8c0e2f4a6b8d0c2e4f6a8b0d2c4e. The cache also serves grpcs on bazel-cache.corp.lan:9092. We do not have the tzdata sha256 yet and the fonts zip has no published checksum - leave those two for us. The three runs per test were only meant for the geometry tests we suspect are flaky." \
  '{task: "fix", code, facts: (.facts | tojson), note: $note} + (if .audit_handoff then {audit_handoff} else {} end)' \
  vendor-check.json)
FKEY="hermetic-build-audit:fix:$(printf '%s' "$FBODY" | shasum -a 256 | cut -c1-16):a1"

# -N turns off buffering; the whole stream lands in $STREAM
STREAM=$(api POST /run-stream -N -H "Idempotency-Key: $FKEY" -d "$FBODY")

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

# the final reply text, from the done (or pending) frame:
REPLY=$(printf '%s\n' "$STREAM" \
  | awk '/^event:/{ev=$2} /^data:/ && (ev=="done" || ev=="pending"){sub(/^data: */,""); print}' \
  | jq -r '.output.output')
# an idempotent replay answers with a plain JSON envelope instead: REPLY=$(printf '%s' "$STREAM" | jq -r '.data.output.output')
printf '%s' "$REPLY" | jq '{verdict, headline,
  files: [(.files // [])[] | {path, characters: (.content | length)}],
  changes: [(.changes // [])[] | {target, flags, change}], verify, open, questions}'

What the page does that you now do

Or skip all of this and use Hermetic Build Audit in the browser: the check, the flags, the repository, rc-line and target inventories, putting an edit or the fixed files back into the paste, and the Markdown, CSV and JSON exports are free.