Hummingbird CVE agent
CLI that investigates open HUM Security tickets labeled
cve-needs-attention or upstream-fix-available, using AI where deterministic
analysis could not resolve the CVE. Fixes are handed to workqueue-service as
mr work items.
Diagram
flowchart TB
TICKET["HUM ticket key"] --> RUN["run HUM-XXXX"]
SWEEP["sweep [--limit N] [--workers N]"] --> SEARCH["Paginated Jira search<br/>open HUM Security tickets"]
SEARCH --> FILTER{"Either scope label?<br/>Not embargoed?<br/>No agent analysis JSON?"}
FILTER -->|eligible| POOL["Worker ticket pool"]
FILTER -->|skipped| REPORT["Sweep report JSON<br/>counts + outcomes"]
subgraph AGENT["CVE Agent"]
direction TB
RUN --> COL["Collect facts<br/>Jira · Pulp · SBOM"] --> CAND{"Backport<br/>candidate?"}
POOL --> COL
subgraph SANDBOX["Ephemeral rpms sandbox · one clone per ticket"]
direction TB
AIINV["AI investigation<br/>read-only SBOM · file tools"]
BACKPORT["Secret-free sparse clone<br/>patches → file_actions"]
end
CAND -->|"affected + fix, current"| BACKPORT
CAND -->|"no · or stale"| AIINV
AIINV -->|"needs_change + fix"| BACKPORT
AIINV -->|verdict| RESULT["InvestigationResult"]
BACKPORT --> RESULT
RESULT --> OUT["Result JSON<br/>stdout · --output"]
RESULT -->|"--post-comment"| COMMENT["Jira comment<br/>+ result attachment"]
RESULT --> REPORT
end
MR["workqueue-service<br/>mr work item · draft RPM MR"]
KONFLUX["Konflux build"]
RESULT -->|"--submit-mr"| MR --> KONFLUX
classDef agent fill:#dbeafe,stroke:#2563eb,color:#111827
classDef sandbox fill:#dcfce7,stroke:#16a34a,color:#111827
classDef external fill:#f3f4f6,stroke:#6b7280,color:#111827
class RUN,SWEEP,SEARCH,FILTER,POOL,COL,CAND,RESULT,OUT,COMMENT,AIINV,REPORT agent
class BACKPORT sandbox
class TICKET,MR,KONFLUX external
The agent runs as a CLI and is stateless: run handles one ticket, while
sweep pages through all eligible tickets and investigates them concurrently.
By default it only prints or persists results locally; Jira comments and MR
submissions are opt-in (see Writes).
Critical evidence network, download, or parse failures abort the investigation;
their local diagnostic report leaves the ticket eligible for a later retry.
Unsupported or ambiguous evidence becomes needs_human. The ticket-level state machine lives in the
CVE Lifecycle & State Vocabulary.
Tl;Dr
-
Scoped to CVE Security tickets - investigates open HUM Security tickets with either
cve-needs-attentionorupstream-fix-available; these are the deterministic-analysis residual and upstream-fix populations respectively. Sweeps skip embargoed tickets and tickets already carrying their<ticket>-cve-agent-analysis.jsonattachment. -
AI in one place - every LLM call goes through
ai.AgentAiClient, which uses the sharedhummingbird_ai.modelslibrary directly, so its token/cost metrics feed the shared GCP AI-costs dashboard. AI judges the fuzzy residual the deterministic path does not own; see What the model sees for the exact context. The model can use bounded read-only tools to search the SBOM, inspect package files, inspect checksum-pinned package sources, and look up exact npm release metadata. Each ticket makes at most five tool-enabled model calls plus one final-answer call, whose request carries no tool definitions so the model must conclude. SBOM searches return at most 20 components with binary-presence evidence (present,source_only, orunknown) from the shared SBOM classifier; a name/version match or source-only hit does not prove the component ships. Package file listings return at most 500 paths. Source archive searches scan at most 48 MiB and 50,000 members; a single file is capped at 8 MiB. Source reads, package-file reads, and upstream patch excerpts share a limit of 12,000 characters each and 48,000 total. npm metadata responses are capped at 16 MiB, and package lookups share a three-call budget. When limits are exhausted, the agent must return its best supported answer orneeds_human. Tool names, result keys, safe counts/provenance, and errors are logged without logging source contents. -
Structured results - every investigation produces a validated, versioned result contract. The result
statusis aVerdictfrom the shared lifecycle vocabulary (hummingbird_cve_analysis/lib/states.py), so the agent andcve_analysisspeak the same state language. See the CVE Lifecycle & State Vocabulary for the full state machine. Every result also carries the ticket’s collection records: each evidence step’s outcome (success,empty,partial,failure, orlimitation), its retryability, and its provenance. The caller fills them; the model neither sees nor writes them. -
Deterministic backports - for an unambiguous single-CVE ticket whose analysis names upstream commits, the agent fetches those commits as patches and mechanically emits MR-compatible patch, spec, and metadata
file_actions. No AI call is needed when the analysis asserted affectedness and still matches the collected package version and NVR; when it punted but still named a fix, AI judges affectedness and the same preparation attaches thefile_actions. AI can also cite an exact upstream commit as structured evidence; deterministic validation still fetches the patch and generates all file actions, so the model never authors a patch. Vendored dependency fixes retain their upstream findings but usually require human adaptation; an AI-selected commit still must pass the same source preflight. PR-only analysis is resolved to its commits first, in apply order (GitHub returns PR commits oldest-first, GitLab newest-first). If a GitHub PR’s original patch does not apply, the agent checks for one merged backport PR linked in the title and targeting the shipped major.minor release line. Its complete PR diff must pass the same source preflight beforefile_actionsare emitted;--submit-mrthen submits those actions as a draft MR work item. If GitHub rate-limits the optional lookup, the original manual-backport result is retained. When source preflight cannot verify a backport for a fast-path ticket, the result isneeds_humanand has nofile_actions. Two rpms-repo conventions are applied:- Patch files are named
<NNNNN>-<cve-id>-<slug>.patch, where the slug is up to five words taken from the Jira summary’s description (for example00003-cve-2026-82474-policy-bypass-allows-unauthorized-program.patch). When one CVE needs several commits, the patch number keeps the names unique. Tickets with no usable summary fall back to the short commit ID. Releasegets a trailing micro bump rather than an increment of the base release, which stays owned by rebases:4becomes4.1,4.1becomes4.2, and16.p2%{?dist}becomes16.p2.1%{?dist}. Date-shaped releases are refused and left for human review.- Clean Fedora-imported packages are marked
modified, with the ticket/CVE appended to any existingmodification_reason. Independent packages stayindependentand omitmodification_reason; other metadata, includingrelease, is preserved.
Branch-tagged upstream commits must match the shipped release line, even when the analysis names only one patch. Mismatches require human review.
- Patch files are named
-
Ephemeral rpms sandbox - one throwaway, uncredentialed clone per ticket, shared by the AI’s read-only tools and deterministic backports, and opened only when needed. Simple
%autosetupspecs usingpatchorgit_amand eitherPatchN:declarations or a literal%patchlist, optionally preceded by%{gpgverify}or%gpgverify -kN -sN -dNand followed by build-input commands, are checked against checksum-pinned source with GNUpatchat zero fuzz or Git’s patch engine. Simple%setupspecs with unconditional%patch N -p1or%patch -P N -p1commands applying every declared patch in numeric order are also supported, including legacy patch headers with backup filename suffixes. Setup-b Nand-a Noptions load checksum-pinned secondary archives before or afterSource0, preserving their locations relative to the build tree. Source is fetched from the rpms repo’s lookaside or the original dist-git’s; upstream monorepo patch paths must match the packaged source. A single shared upstream directory prefix is stripped only when every stripped path is present in the pinned tarball and none of the original paths are; the adapted patch still must pass source preflight. Unsupported%autosetuperrors show the offending line. Full patch-stack replay rejects conditional patch declarations; the existing non-overlapping direct-preflight bypass remains available for supported conditional or filtered%autopatchpreparation.
Prerequisites
- Python 3.11+
- GNU
patchfor automatic backports (gpatchon macOS); unsupported or unavailable patch tools leave the ticket for human review. - Git for
%autosetup -S git_ambackports. GITHUB_TOKENfor GitHub backport lookup when unauthenticated API requests hit GitHub’s rate limit.- Jira API credentials (
JIRA_TOKEN, andJIRA_USERwhere basic auth applies) - Vertex AI access for the AI path:
GOOGLE_CLOUD_PROJECT, orGOOGLE_API_KEYfor the direct Gemini API. The deterministic fast path needs neither. - For
--submit-mronly:WORKQUEUE_SERVICE_URLand aWORKQUEUE_SERVICE_TOKENbearingsubmitpermission on themrwork type.submitcomes from membership in thekonflux-hummingbird-admin-accessgroup. Check withcurl -H "Authorization: Bearer $WORKQUEUE_SERVICE_TOKEN" "$WORKQUEUE_SERVICE_URL/api/whoami".
Installation
make hummingbird-cve-agent/setup
The target installs the in-repo dependencies (hummingbird-ai,
hummingbird-cve-analysis) editable alongside this package. Equivalent by hand:
cd hummingbird-cve-agent
pip install -e ../hummingbird-ai -e ../hummingbird-cve-analysis -e ".[dev]"
For the environment, copy hummingbird-cve-agent/.env.example to .env and
source it for a quick way to set up all necessary variables.
If the workqueue endpoint uses an internal CA, export REQUESTS_CA_BUNDLE
in the same shell as --submit-mr. Its PEM file must contain both the public
roots (for Google OAuth) and the internal root (for the workqueue endpoint).
Usage
hummingbird-cve-agent run HUM-1234
hummingbird-cve-agent run HUM-1234 --output results/HUM-1234.json
hummingbird-cve-agent run HUM-1234 --post-comment
hummingbird-cve-agent run HUM-1234 --submit-mr
hummingbird-cve-agent sweep
hummingbird-cve-agent sweep --limit 25 --workers 6 --output sweep.json
hummingbird-cve-agent sweep --post-comment --submit-mr
This gathers facts for the ticket, runs the investigation, and prints the
result as JSON or persists it to --output. Eligible backports include
base64-encoded file_actions and a commit_message ready for an mr work
item; everything else is judged by the AI client. A backport that cannot be
prepared is not retried by AI — a patch that fails preflight needs human
adaptation before submission. Failing to prepare one does not weaken the
verdict: the finding and its confidence stand, the summary records why the
automation stopped, and a needs_change result without file_actions already
reads as a human-owned backport.
CLI logs include ticket and package context, e.g. - INFO (HUM-1234 openssl): collected facts for HUM-1234; the package is - until known, and unscoped logs
show - -.
sweep defaults to all eligible tickets and four concurrent workers. --limit
caps attempted investigations, including failed attempts; analyzed, embargoed,
and out-of-scope search results do not consume the limit. The aggregate JSON
report lists scan/skip/attempt/success/failure counts and each attempted ticket’s
result or error. The command exits nonzero if any ticket fails, while continuing
other investigations. Without --post-comment, the sweep does not add the
attachment used to mark a ticket as investigated, so a later sweep can retry it.
Critical collection failures never post a recommendation or analysis attachment,
even when the write flags are enabled. The CLI and sweep retain bounded failure
records locally; transient failures are marked retryable and are not cached.
Writes: Jira comment and MR submission
Both writes are off by default; each is enabled solely by its own flag, so a
bare run never touches Jira or GitLab. The two flags are independent — a
comment can be posted without submitting an MR and vice versa — and a failed
write exits non-zero.
--post-commentposts a deterministicRECOMMENDATION: ...comment rendered from the validatedInvestigationResult— no AI call at comment time — throughcve_analysis’sjira_client, which handles ADF formatting and duplicate suppression, so re-running a command is idempotent. The full result JSON is attached to the ticket as<TICKET>-cve-agent-analysis.json: the comment carries the verdict, summary, cited evidence, and the next review step when human review is needed, including when a necessary package fix has no automated backport. The attachment carries the full evidence, file actions and commit message it was derived from. The attachment is skipped alongside a duplicate comment, so an unchanged re-run adds nothing.--submit-mrsubmits a draft MR work item to a runningworkqueue-serviceinstance (work items default toauto_merge=false); Konflux performs the package build verification. It is a no-op unless the result carries a prepared backport (file_actions+commit_message). Submission POSTs toWORKQUEUE_SERVICE_URLwith theWORKQUEUE_SERVICE_TOKENservice-account token; the agent is headless, so there is no interactive login fallback. The submitted work item’s description includes the package, CVE, built NVR when available, selected upstream commits, investigation reasoning, and pending Konflux and human checks. Currently,workqueue-serviceonly applies this description when updating an existing MR.
Eval harness
eval scores investigations against completed human /cve sessions. The eval
set, eval-tickets.txt, holds Jira ticket keys and nothing else — ticket
content is internal data and is never committed — so facts are fetched live and
running the eval needs Jira access.
hummingbird-cve-agent eval eval-tickets.txt --output eval-report.json
Read-only: every Jira request is a GET (issue, comments, changelog);
test_evaluate asserts it.
A resolved ticket gives the answer away — fixed_in_build set, labels moved to
awaiting-vex, status Closed, newest analysis comment post-fix — so each ticket
is rewound through its changelog, dropping later comments.
The rewind cutoff is the handoff, not the close: cve_analysis records
Fixed in Build and posts its assessment before it transitions the ticket, so
the cutoff is the earlier of the resolution and the first recorded fix build. A
reopened ticket rewinds to its first build, which is deliberately conservative —
rewinding too far only costs context, rewinding too little hands the agent the
conclusion. The fix build is additionally blanked outright, so a build recorded
outside the changelog cannot leak either.
What is scored
Two axes, because one is not enough:
- Disposition — where each side says the ticket ends up. The verdict is
mapped to its eventual disposition, not through
states.VERDICT_OUTCOME: that shared table answers “what does the agent do next”, soneeds_changeis In Progress — but a ticket the human backported still endsDone-Errata, and scoring against the immediate state would mark a correctneeds_changeas a miss and reward a wrongalready_fixed. - Route — which branch the orchestrator would take, since
already_fixed(Fixed in Build) andneeds_change(MR creation) share theDone-Errataending and are indistinguishable on the first axis. The human’s branch is derived from Pulp upload times: a fix build published after the handoff means the package had to change (needs_change), an earlier one means the shipped package already carried the fix (already_fixed); aNot a Bugclosure isnot_affected. A build Pulp no longer lists is reported asn/aand excluded from the route denominator rather than guessed.
Verdicts that reach no conclusion (needs_human, error) and conclusions no
verdict can produce (unknown resolutions) never match. Package EOL Won't Do
closures are deterministic cve_analysis outcomes and excluded before the AI
eval runs. A ticket that cannot be rewound or investigated scores error
without aborting the run.
HUM-7554 agree verdict=already_fixed expected=Closed / Done-Errata actual=Closed / Done-Errata route=!=needs_change
HUM-7537 DISAGREE verdict=needs_human expected=Closed / Won't Do actual= route=n/a
disposition agreement: 1/2 (50%)
route agreement: 0/1 (0%), 1 undetermined
note: package facts (SRPM, SBOM, rpms repo) are current state, not the state at decision time, so agreement is retrospective
Retrospective, not historical replay
Only the Jira side is rewound. Package facts — the latest SRPM, the latest SBOM,
and the rpms repo the sandbox clones — are read live, so an old ticket is
re-investigated against today’s package state rather than the state that existed
when the human decided. Where the fix has since shipped, already_fixed is
partly handed to the agent, so the score is retrospective current-state
agreement, not a historical replay; every report prints that caveat. True
replay needs as-of-cutoff SRPM and SBOM selection plus a date-pinned rpms
checkout, and is deliberately left to a follow-up.
Configuration
Operational settings come from the YAML config file; secrets and model credentials come from the environment.
| Variable | Purpose |
|---|---|
HUMMINGBIRD_CVE_AGENT_CONFIG |
Path to a YAML config file (optional) |
JIRA_TOKEN |
Jira API token |
JIRA_USER |
Jira basic-auth user (optional) |
GITHUB_TOKEN |
Read-only GitHub token for PR commit lookups (optional) |
GITLAB_TOKEN |
Read-only GitLab.com token for MR commit lookups (optional) |
GOOGLE_API_KEY |
Gemini API key (direct API mode) |
GOOGLE_CLOUD_PROJECT |
GCP project for Vertex AI mode |
WORKQUEUE_SERVICE_URL |
Base URL of a running workqueue-service instance (for --submit-mr) |
WORKQUEUE_SERVICE_TOKEN |
Bearer token for workqueue-service (required for --submit-mr) |
YAML settings:
| Key | Default | Purpose |
|---|---|---|
jira_url |
Red Hat Jira | Jira base URL |
model |
claude-sonnet-4-6 |
Model name passed to build_model |
model_regions |
Gemini and Claude region prefixes | Model-name prefix to Vertex region |
Neither forge token grants write access: they only authenticate the read-only API calls that resolve a pull/merge request to its commits, and are needed only for private or rate-limited repositories. The sandbox never holds a push token.
What the model sees
Only non-candidates reach the model.
The prompt is a static system instruction encoding the /cve resolution process,
plus the validated TicketFacts as JSON: ticket key, package, CVEs, Jira
summary/description/labels, status/resolution/fixed_in_build,
cve_analysis_block, discussion, and the built NVR and SBOM identity.
- The SBOM document is excluded. Only its identity (
sbom_nvr,sbom_url) is in the prompt; the multi-megabyte SPDX body is reachable only through the boundedsearch_sbomtool. Binary-presence evidence comes from SPDXsourceInfoandCONTAINSrelationships to RPM roots;unknownrequires further investigation. RPM repo reads inspect packaging files and patches, not necessarily the built upstream source or installed binary payload. - Package-directory search. A bounded literal
search_package_filesgrep locates evidence spread across packaging files — whether a build config enables the vulnerable applet, which patch names a CVE — without spending the per-file read budget. An empty result is not proof of absence. - Other-package evidence.
lookup_shipped_packageresolves a candidate name to its latest source-package SBOM and reports the NVR, component version, and binary presence (present,source_only, orunknown). Onlypresentconfirms the named component ships; source-only or missing SBOM data remains inconclusive. Lookups are capped per ticket. - Package source and npm evidence. Source reads/searches use the
checksum-pinned archives declared in the current package spec, replaying
supported downstream patches without running spec scriptlets. Results report
the spec version/release, rpms-repo revision, archive filename/checksum, and
ticket NVR, with
built_nvr_match: unverified. This is evidence about the inspected packaging revision; it does not prove that revision built the ticket NVR unless the match is established. Source inspection refuses conditional patch declarations andpatch_directpreparation rather than guessing which patches RPM applies.read_package_sourceandread_package_fileaccept optionalstart_line(1-based) andmax_linesarguments and reportstart_line,end_line, andtotal_lines. An empty window hasend_line: null. The existing per-read and shared character budgets still apply;truncatedindicates character clipping within the requested window. SBOM search includes SPDX package URLs, including scoped npm identifiers. Matches with multiple versions of the same package name report binary presence asunknown, including PURL matches, to avoid mixing evidence across versions. PURL-based classification requires its embedded version to match the package’sversionInfo; missing or mismatched PURL versions use the name-based path. Positive PURL evidence reporting a different version is treated asunknown. Full npm registry metadata can provide exact-version publication time,gitHead, repository, and integrity, but neither a version date nor metadata alone proves that the dependency ships or contains a fix. - Known fix against a release archive.
check_upstream_fixfetches a known commit patch from a supported HTTPS forge (2 MiB maximum, redirects disabled) and checks it against the prepared, spec-pinned source after downstream patches. The result records source provenance and apatch_diffexcerpt, capped at 12,000 characters and charged against the shared 48,000-character source/file read budget.patch_truncatedidentifies an incomplete excerpt. An exhausted budget returns an error before source preparation or patch fetching and does not consume the fix-check attempt. Tool logs report patch applicability and affected-path counts without logging the diff content. A patch that applies shows that exact diff is absent from the inspected source snapshot, not that the vulnerable code is reachable or that the snapshot built the ticket NVR. A patch that does not apply is inconclusive. Failed source checks, an absent CVE-named patch,%filesentries, and missing disable flags alone do not establish affectedness, shipped presence, or fix absence; unresolved cases requireneeds_human. - Upstream source probe. Once per ticket, the model may inspect an official
upstream HTTPS Git repository. It searches shallow, blobless, tagless history
and advisory/security metadata for the CVE or a supplied fix commit. When an
exact base commit is known, it also checks fix ancestry and runs
git apply --check; without one it reports candidates and changed paths only. A candidate withresolved: falsecould not be fetched and is not evidence. Other candidate results are retained as partial evidence; network failures abort the run while preserving that partial result locally. Ifbase_commit_resolvedis false, ancestry and applicability are unknown. Short SHAs that Git cannot fetch are reported unresolved rather than expanded through a forge API. GitHub and GitLab web repository URLs are normalized to.gitclone endpoints; HTTP redirects remain disabled. An applicable or non-applicable patch alone does not establish affectedness or fix inclusion. No candidate in this bounded search requiresneeds_human, notno_upstream_fix. Collection failures abort the run; unresolved refs remain partial evidence. The Job running the agent must enforce CPU, memory, and ephemeral-storage limits for this external fetch. - Non-retryable tool results. A result with
retryable: falseindicates a cached capability failure, consumed one-shot probe, or exhausted per-ticket budget. Repeating the call will not change it; the model records the evidence gap and moves on. - The model cannot author a patch. The response schema is
InvestigationResultminusCALLER_FILLED, so it returns onlystatus,confidence,summary,evidenceandvex_justification;file_actions,commit_message,ticket,packageandcvesare caller-owned and rejected if set. It can cite an upstream commit as evidence — never write one.
Security
The agent processes untrusted input — Jira ticket content, upstream commits, and package spec/patch files — so the sandbox is built so that no untrusted code is ever executed and untrusted input cannot reach secrets or a shared host.
- No untrusted code execution. The sandbox clones the rpms repo, reads
files, resolves upstream PRs, downloads patches, and checks them with GNU
patchor Git’s patch engine before staging. It never runsrpmbuild, a spec’s%prepscriptlet, or any code supplied by a spec or patch. Verifying the fix by building the package — the step that would execute spec-controlled code — is deliberately delegated to Konflux, which builds the resulting MR in its own hardened pipeline. This keeps a whole class of remote-code-execution surface (and slow, resource-heavy builds of large packages) out of the agent entirely. - Deployment isolation. The agent ships as a container image and is meant to
run as a Kubernetes Job or inside
workqueue-service; it is not deployed yet. The rpms fork lives in a private temp dir inside that boundary and is removed on exit, so git operations run inside the deployment sandbox rather than on a shared host — no separate nested container is needed, because nothing here executes untrusted code. - Secret-free subprocesses. git runs with a minimal, allowlisted environment. The service process env (Jira and model tokens) is never inherited by child processes, so a subprocess cannot read or exfiltrate them.
- Uncredentialed, hardened git. The fork is a public, read-only clone with
no push token. Credential helpers and interactive prompts are disabled,
system/global git config is neutralized (an attacker-controlled
GIT_CONFIG_GLOBALcannot change behaviour), and submodule recursion is off. - Input validation. Package names are restricted to a safe charset (no path
traversal or absolute paths), file reads are confined to the package
directory, saved patch names must be
*.patchand cannot overwrite existing files, and patch URLs must use HTTPS on a recognized upstream forge.
Development
See the main README for development workflows.
make check # lint
make test # run tests
License
This project is licensed under the GNU General Public License v3.0 or later - see the LICENSE file for details.