build: pin the Duo CLI binary by sha256 and parse the 9.x JSON contract
Summary
Restores a working GitLab Duo CLI path on the absorbed base, in two halves that have to land together:
- Supply chain. Pin the Duo CLI compiled binary directly (exact version,
SHA256-verified, hard-fail on mismatch at build) instead of routing through
glab duo cli run. - Output contract. Adopt
duo run --output-format jsonas the parse contract at every call site, because 9.x changed the output shape the analyzer parsed, and extend the fail-loudly guard so a contract break turns the pipeline red instead of degrading to silentNot_Reviewedverdicts.
Target branch master. !6 (merged) (the 1.2.4 absorb) has merged, so this no longer
stacks on anything.
Why: the absorbed mechanism does not run, and the pin alone would not fix it
The 1.2.4 absorb (!6 (merged)) pulled in a fork-parent commit
(fix(duo): migrate to glab-managed Duo CLI binary, 2026-06-23) that switched
the analyzer from the npm @gitlab/duo-cli package to a glab-managed
compiled binary, reasoning that the npm CLI failed GitLab's instance-version
handshake in CI.
Two things are true about that mechanism as absorbed:
glab1.105.0gatesglab duo cli runon Duo CLI major8. It resolves the package registry's latest release, major9, and refuses to drive it. The image's pre-downloaded8.107.0binary is consequently never invoked.- The build's own probe for this (
glab duo cli version || echo "note: ...") is||-swallowed, so the mismatch never fails the build. It just prints a note that nothing reads.
A live check against gitlab.com on 2026-08-12 shows 9.10.0 authenticates
without issue; the June diagnosis that this CLI cannot authenticate did not
reproduce.
The part a version pin alone gets wrong. Between 8.107.0 and 9.10.0 the
CLI changed its non-interactive output contract:
- all logging moved to stderr;
- the per-message
[RunController]logging is gone from the binary, not merely relocated; - text mode now writes only the bare final response text to stdout.
parse_gitlab_duo_output() required a literal [RunController] marker and
brace-counted from it, so against real 9.10.0 output it raises. That
error does not fail the pipeline: it becomes a per-rule Not_Reviewed verdict
under a green pipeline — the exact failure mode this project's guards exist to
prevent, reintroduced by the fix meant to remove it. The existing tests passed
only because their fixtures encoded the dead 8.x text contract, so they
proved nothing about the pinned binary.
--output-format json first shipped in 8.110.0, after the old pin. 9.10.0
has it, and its help text states the contract outright: "json emits a single
JSON document to stdout and routes logs to stderr."
Approach
Supply chain
-
Download the compiled binary directly, no glab coupling.
duois pulled from the same GitLab Package Registry endpoint the absorbed Dockerfile already used (gitlab.com/api/v4/projects/46519181/packages/generic/duo-cli/..., thegitlab-org/editor-extensions/gitlab-lspproject), pinned to9.10.0, SHA256-verified, and installed straight to/usr/local/bin/duo. No glab-managed download, no npm, no Node involved in installing it. Uses the baseduo-linux-x64, not-modern: the AVX2-optimized build can SIGILL on runners whose CPUs lack AVX2, and we don't control which runner executes. -
Hard build-time check, not a swallowed probe.
duo --versionis the last statement in theRUN, with no||anywhere in the chain: a wrong, corrupted, or unrunnable binary fails the image build. -
glabnarrows to one job. It stays in the image, pinned, solely to back the token-liveness probe inverify_duo_creds.sh(glab api user).glab config set --global duo_cli_auto_run/auto_downloadis removed so glab can never manage or overwrite the pinned binary — PATH-shadowing eliminated, not merely discouraged. -
Exact pin, deliberately.
gitlab-lspships one semantic version across its whole monorepo per commit, andv8.84.0shipped a documentedBREAKING CHANGESentry (removal of--connection-type) while bumping only minor (changelog). The Dockerfile comment now says the pin is an output-contract pin as much as a supply-chain one, and names the three places a bump must be re-checked against. -
Renovate actually tracks the pin.
ARG DUO_CLI_VERSIONcarries# renovate: datasource=gitlab-packages depName=gitlab-org/editor-extensions/gitlab-lsp:duo-cli versioning=semver. The binary is a Package Registry generic package, sogitlab-packages(packageNameinproject/path:package-nameform) is the correct datasource, notgitlab-releases/gitlab-tags, which track releases and tags rather than package-registry contents. An annotation alone is inert here — the shared preset (gitlab>gitlab-com/public-sector/pipeline:renovate-config) scopes itsARG *_VERSIONmanager tocontainers/*.Containerfile— so this MR carries therenovate.jsoncustomManagers in with it, absorbed from !4 (merged) (see Related):- one manager for both
ARGpins instig_tools/Dockerfile, widening !4 (merged)'s regex to a(GKG|DUO_CLI)_VERSIONalternation with an optionalversioning=capture, which is what lets the Duo CLI pin declaresemverover a datasource whose raw file names are not semver-ordered.ARG GKG_VERSIONgains thegitlab-releasesannotation it needs, carried from !4 (merged) unchanged; - one for the
npm install --global @gitlab/duo-cli@<version>line in the sample application'snotify:mac-gunjob (app/.gitlab-ci.yml), carried from !4 (merged) along with its pin — that line was floating unpinned onmaster; - a
packageRuleholding both tools atautomerge: false, so a bump is always a merge request a maintainer reads; - a PR note on Duo CLI bumps specifically, because
DUO_CLI_SHA256has to be recomputed by hand (Renovate cannot digest a generic Package Registry file), and because the output contract must be re-checked against both parsers and the canary. A version-only bump fails the build atsha256sum -cby design.
All three regexes were run against the real files to confirm they match:
DUO_CLI_VERSION=9.10.0(withversioning=semvercaptured),GKG_VERSION=v0.25.0, and@gitlab/duo-cli@9.10.0. - one manager for both
Output contract
- One contract, everywhere. Every Duo CLI invocation in the repo passes
--output-format jsonand parses the CLI's result document:schemaVersion "1.0",sessionId,exitCode,response,elements,status, pluserrorwhenstatusis"error". parse_gitlab_duo_output()rewritten to that contract.json.loads(stdout), takeresponse. The existing markdown-code-fence stripping is kept and applied to that response text, factored out asstrip_markdown_fence()so it is separately testable. No text-mode fallback at all — the shape a fallback would guess at no longer exists in the binary, and guessing is how a contract break becomes a green pipeline full of unreviewed rules.- Fail-loudly guard extended. Until now only a rejected token aborted the
run. Non-JSON stdout, or a document without a string
response, now raisesDuoOutputContractError, is flaggedparse_contract_failedthroughcall_gitlab_duo()andanalyze_rule_with_ai(), and abortsmain()with a diagnostic pointing atDUO_CLI_VERSION. Astatus: "error"document is a failed run, not a broken contract, so it stays an ordinary per-rule error — as does an emptyresponse. - All three remaining call sites converted, the ones the previous draft of
this description wrongly dismissed as "comments":
stig_tools/remediate.pyrun_duo()— reports a stdout that is not the expected document as a failure, which aborts that finding before any branch, commit or merge request is created. (The protection is that no MR is opened at all;build_mr_body()never renders the Duo summary it is handed.) Missing or non-stringresponseboth fail, matching the analyzer. A non-zero exit reports stderr first, labelled and separated from stdout, since that is where the diagnostics live now.test_gkg_duo_mcp(.gitlab-ci.yml) andstig::gkg-smoke-test(templates/stig-compliance-analysis.yml) — both keep the streams separate rather than merging with2>&1: the JSON document to stdout, and the[MCP Manager] knowledge-graph: Tool executed: ...lines the assertions grep for to stderr, where logging moved. Merging would leave the document interleaved with logs and unparseable. Both jobs additionally assert the contract against the real pinned binary, and keep the stdout document as an artifact.
- The canary repointed, not left asserting a fiction. !5 (merged) landed
duo_cli_canarywhile this branch was in flight. It asserted the[RunController]text shape as "TODAY'S CONTRACT" and JSON mode as a "migration target ... not yet consumed by the analyzer". Both labels invert here, and the text assertion would now fail on master — the only ref carryingGITLAB_DUO_TOKEN, hence the only place its live assertions run. The text assertion is dropped; the JSON one is promoted to the live contract and strengthened from "stdout parses as JSON" to "stdout is one object carryingschemaVersion,status, and a stringresponse", the fields the parsers actually read.
Verified
- Schema derived from the artifact, not assumed. A live run was attempted
first with the supplied project access token: it authenticated
(
/api/v4/personal_access_tokens/self→ 200) but the run aborted at initialization because the token carries only theai_featuresscope and the CLI'sgetUserGraphQL query needs more, so stdout was empty and no document was captured. The schema therefore comes from binary inspection: the embeddedRunResultWriterand its zod schema in the downloadedduo-linux-x64, which builds{schemaVersion, sessionId, exitCode, response, elements}and addsstatus: "success"orstatus: "error"+error, thenJSON.stringify(doc, null, 2)toprocess.stdout.responseis thecontentof the lastelements[]entry withtype: "message",role: "assistant",isComplete: true, or""when there is none. Element types come from the binary's own enum ({MESSAGE, TOOL, ERROR, INFO, SKILL_CONFLICTS}). That failed run did independently confirm the review's other finding: every log line went to stderr, stdout stayed empty. - URL and hash, independently. Downloaded
https://gitlab.com/api/v4/projects/46519181/packages/generic/duo-cli/9.10.0/duo-linux-x64(HTTP 200, 103290216 bytes) and computed its SHA256:77d35130174a87126340acce38101bdf53213b741103d492971f23bd50558dc9, matching the pin.filereports a valid ELF 64-bit x86-64 executable. --output-format jsonbehaviour, from the CLI itself.duo run --helpat the pin documents it as emitting "a single JSON document to stdout" and routing "logs to stderr". The committed canary snapshot (tests/fixtures/duo_run_help_9.10.0.txt, generated from the npm package) matchesduo run --helpfrom the downloaded binary byte for byte. That is evidence the two distributions agree on the CLI surface at this pin, not a guarantee that they always will, and not a check of the binary the image ships — that binary's integrity is proven separately by the SHA256 verification and the hardduo --version. The job comment now says exactly that, rather than overclaiming equivalence.duo --versionreports9.10.0from the pinned artifact (run on thedarwin-arm64build of the same version; thelinux-x64build cannot execute on this host). The CI pipeline on this MR is the first real-hardware run of the newRUNblock.- Tests. Full suite
pytest tests/(equivalentlypython3 -m unittest discover -s tests -p "test_*.py"): 70 → 108 passing, 0 failures.test_ai_*(the pattern thetest_duo_parserCI job discovers): 18 → 46. Every added test exercises the new contract, including the fail-loudly paths: non-JSON stdout (8.x-style log text specifically), empty and whitespace-only stdout, a JSON array, a missingresponse, a non-stringresponse, and the escalation from parser →call_gitlab_duo()→analyze_rule_with_ai(). Thetest_duo_parserjob now also discoverstest_remediat*so the second call site's contract is checked in CI rather than only locally. - CI config linted server-side.
glab ci linton both modified CI files (.gitlab-ci.ymlandapp/.gitlab-ci.yml): valid.renovate.jsonparses. Both job scripts were extracted from the YAML and passedsh -n, and the embedded contract-assertion Python was executed against a good document, a malformed one, and one with a non-stringresponse(exit 0 / 1 / 1 as intended). - Refusal detection, against the real traces. The entitlement classifier
and the canary's shell alternation are both tested against the verbatim
stderr lines above (
AgenticChatForbiddenError ...and the upstream404 Namespace Not Found), asserting they classify as ENTITLEMENT, thatinvalid_tokenstill classifies distinctly and wins when both appear, that unrecognized output (e.g.ECONNRESET) is not treated as a refusal, and that the flag propagates throughcall_gitlab_duo()andanalyze_rule_with_ai()to the abort guard. One test asserts the canary greps for every signature induo_auth.py, so the shell mirror cannot drift. - No dead contract left in the tree.
grep -rn RunControllerreturns only prose explaining why the old shape is gone, plus the one negative test fixture that asserts it must now raise. No live code parses or asserts it.
Known state: pipeline red, and it is not this diff
The pipeline on this branch is red for exactly one reason: the CI bot token has no GitLab Duo Agent Platform entitlement. Admin action is pending. Nothing in this diff is implicated, and no re-run will change it.
The token authenticates cleanly and is then refused by the server. The refusal surfaces on stderr as:
[error]: Failed to verify Agentic Chat access: Failed to create workflow: HTTP 404. 404 Namespace Not Found
[error]: AgenticChatForbiddenError: GitLab Duo Agent Platform is not available for this namespace or projectThe first run of this branch handled that badly, and it has been fixed. The
fail-fast guard recognized only invalid_token / "Token is invalid or expired",
so an entitlement wall fell through to generic per-rule errors and surfaced only
at the end, via the all-Not_Reviewed guard — correct outcome, but late and
labelled as if the token were dead. The canary was worse: it printed "exited 1
without a recognizable auth error... This may be transient — re-run once" over a
permanent refusal.
Both now name it. stig_tools/duo_auth.py holds the signatures
(AgenticChatForbiddenError, GitLab Duo Agent Platform is not available,
Failed to verify Agentic Chat access, Namespace Not Found,
insufficient_scope), used by both Python call sites and mirrored in the
canary's shell greps. They take the same fail-fast path as a bad token but
report ENTITLEMENT rather than INVALID TOKEN, and say outright that
refreshing the token will not help — the fix is a Duo seat for the token's
owner, Duo enabled for the namespace/project, and the ai_features scope.
So the failure now reports itself as precisely what it is. What this pipeline still cannot prove is the happy path: no live Duo call has succeeded on this branch, so the JSON result document has been verified against the artifact and in unit tests, but not yet end to end in CI. That waits on the entitlement.
Scope decisions
- Base image: left as
node:22-slim, out of scope here. The image has had no functional dependency on Node/npm since before this MR: nonode/npm/npxinvocation anywhere in it, GKG is a Rust binary, and thestig_tools/*.sh/*.pyscripts usesh/python3only.node:22-slimtraces back to the project's original design (RUN npm install --global @anthropic-ai/claude-code @gitlab/duo-cli, commite6f8f0d) and stayed pinned as the base through every later migration. Switching it (to something likepython:3.12-slim, matchingapp/Dockerfileandaws/Dockerfile) touches every apt install in the file and needs its own verification that GKG install, pip installs, and glibc compatibility with the Bun-compiledduobinary hold on the new base. Named as a follow-up. Note theduo_cli_canaryjob still usesnode:22-slimdeliberately — it installs the npm package and genuinely needs a Node runtime. app/'s own Duo usage untouched.app/.gitlab-ci.ymlandapp/README.mdreferenceduo run/glab duo ask;app/is the sample application under scan, not this tool's invocation of the CLI.- Token is passed by environment, never on argv. The pre-redraft draft of
this branch added
--gitlab-auth-token <token>at four call sites. That was new exposure measured againstmaster, whose call sites were env-only: a token in argv is readable from/proc/<pid>/cmdlineby anything else in the job container. Removed everywhere;GITLAB_TOKENis the sole channel. Confirmed from the pinned binary that the two are interchangeable — the option definition is{flags: "--gitlab-auth-token <token>", env: "GITLAB_TOKEN", default: process.env.GITLAB_OAUTH_TOKEN}and the credential provider labels the source "env var / --gitlab-auth-token" — and confirmed by running the artifact with the env var and no flag: the CLI logged[CredentialProvider] Using static token from env var / CLI flagand reached the same authenticated GraphQL call, where with no token at all it falls back tofrom config file. Tests assert the token reaches the subprocess environment and neverargv.
Related
- !6 (merged) (!6 (merged)),
"chore: absorb upstream 1.2.4 (fork parent)" — merged; this MR targets
masterdirectly. - !5 (merged) (!5 (merged)),
"ci: add Duo CLI output-contract canary" — merged while this branch was in
flight. Reconciled here rather than left conflicting: its "today's contract"
and "migration target" labels are inverted by this change, and its text-mode
assertion asserts a shape
9.10.0does not emit. - !3 (merged) (!3 (merged)),
"feat: extract CCI identifiers from XCCDF parsing" — merged while this
branch was in flight. It added
TestExtractRuleCcistotests/test_ai_parsers.py, the same file this MR rewrites; the rebase keeps both (its CCI class and this MR's JSON-contract classes). - !4 (merged) (!4 (merged)),
"build: pin @gitlab/duo-cli to an exact version" — absorbed into this
branch, which reworks it in place. It independently reached the same version
conclusion (
9.10.0, samev8.84.0breaking-change-in-minor evidence) but through the pre-absorb npm mechanism, which no longer exists instig_tools/Dockerfile. Its two durable pieces are carried across rather than discarded: therenovate.jsoncustomManagers (itsARG GKG_VERSIONmanager widened to coverDUO_CLI_VERSIONtoo, its npm manager kept forapp/) and its pin of the floatingnpm install --global @gitlab/duo-cliinapp/.gitlab-ci.yml. Itsstig_tools/Dockerfilenpm-install pin is superseded by the direct binary download.
Checklist
- Focused commits, imperative subjects, wrapped bodies explaining the why
-
duo --versionis a hard, unswallowed build-time check - SHA256 computed independently from a direct download, not copied from documentation or assumed
- JSON output schema derived from the pinned artifact (binary inspection of
RunResultWriter), stated as such rather than assumed; the live-capture attempt and why it failed are recorded above - Every Duo CLI call site converted to
--output-format json— analyzer,remediate.py, both CI jobs, and the canary - Parse-contract failure fails the pipeline; it can no longer degrade to
Not_Reviewed, and that path is covered by tests - Test fixtures migrated off the
8.xtext contract; counts rose 70 → 108 (full) and 18 → 46 (test_ai_*), all passing -
grepconfirms no live code parses or asserts the dead text contract -
glab ci lintvalid; both CI job scripts passsh -n - CHANGELOG
[Unreleased]entries added under the file's existing### Fixed - <title>/### Changed - <title>convention -
docs/FEATURES.mdcorrected — it described the deleted[RunController]streaming parser as the analysis engine - No secrets committed; the SHA256 and package URLs are public
- Duo CLI token passed by environment only, never argv — verified against the pinned binary and asserted by tests at both call sites
-
renovate.jsoncustomManagers carried in from !4 (merged) and widened; all three regexes verified to match the realstig_tools/Dockerfileandapp/.gitlab-ci.ymlpins, so no annotation is inert - !4 (merged)'s
app/.gitlab-ci.ymlpin of the floatingnpm install --global @gitlab/duo-clicarried across unchanged - Duo refusals are classified and named — entitlement rejection reported as entitlement, not as a dead token and not as "may be transient"
- CI pipeline green on this MR — blocked on the CI bot token's Duo Agent Platform entitlement (admin action pending), not on this diff. Every live Duo call is refused before it starts; see "Known state" above
- Base-image cleanup (
node:22-slim→ a Node-free base) filed as a follow-up, not done here