Repository navigation
feat(cve-tool): run CVE-record operations through the configured CVE tool - #2062
sjyangkevin wants to merge 7 commits into
Conversation
tools/cve-tool/README.md defined a tool-neutral CVE-record interface, but nothing implemented it, so every caller ran Vulnogram's own commands. `cve-tool <operation>` resolves `cve_authority.tool` (--tool, then project.md, then the organization manifest) and runs that tool's adapter. The Vulnogram adapter calls the existing vulnogram_api commands with the arguments unchanged, and translates states at the edge: `fetch --state-only` prints the generic state, and `publish --allow-state` takes one. A tool without an adapter (mitre-form, ghsa, ...) and an operation an adapter lacks (Vulnogram's retract) are refused with their own exit codes. The command is not called by anything yet; the helpers and skills move to it in the following commits. Refs apache#1800 Generated-by: Claude Code (Opus 5.5)
…nogram's The issue-sync push decision and the cve-allocate pre-flight, recipe and rollup printed `uv run --project <framework>/tools/cve-tool-vulnogram/oauth-api vulnogram-api-*` commands, so every run went straight to Vulnogram whatever `cve_authority.tool` said. They now print `cve-tool check|setup|allocate|push|fetch`, built in one place (shared/cve_tool.py), and the verify step reads the generic state `cve-tool fetch --state-only` prints as well as the native one. The unused `oauth_api` path override becomes `cve_tool_project`. Messages about allocate exits say "the CVE tool" rather than Vulnogram. For an ASF project the same Vulnogram commands run underneath. Refs apache#1800 Generated-by: Claude Code (Opus 5.5)
The issue-sync and cve-allocate skills told the agent to run Vulnogram's commands (`vulnogram-api-record-update`, `-record-fetch`, `-record-publish`, `-allocate`, `-check`, `-setup`) and gated decisions on Vulnogram's states (`state == REVIEW`, "if the state is PUBLIC"). They now run `cve-tool push|fetch|publish|allocate|check|setup` and speak in the generic states (`allocated`, `review-ready`, `publish-ready`, `public`). This is required, not only wording: `cve-tool fetch --state-only` prints the generic state, so a check written against `REVIEW` would no longer match. Vulnogram names stay where they describe the ASF example or the generated JSON, which is still Vulnogram-shaped. It also fixes the reviewer-comment fetch in cve-record-comments.md, which pointed at the non-existent `tools/<cve-tool>/oauth-api`. The step-5b eval fixtures follow the new commands; the issue-invalidate hard stop and the process doc name the command the agent runs. Refs apache#1800 Generated-by: Claude Code (Opus 5.5)
…me notes The contract README described itself as a spec with no code, said every CVE-aware skill "speaks Vulnogram", and carried several notes about a pending "PR4" rename of tools/cve-tool-vulnogram/ to itself. It now documents the `cve-tool` command: how it resolves `cve_authority.tool`, the operation -> contract method -> Vulnogram command table, the state translation, the 20/21/22 exit codes and how to add an adapter. The skill list matches what the skills do (issue-sync pushes at Step 5b; issue-invalidate stops for a retraction rather than calling it), and the configuration section reflects organization inheritance. issue-deduplicate and issue-invalidate named the contract methods abstractly (`<cve-tool>`'s `push_update` / `retract` / `fetch_current_state`); they now give the `cve-tool` command to run. The labels-and-capabilities row no longer calls the contract code-free, and the cve-tooling spec covers the command (acceptance criterion 10) and its known gaps. Refs apache#1800 Generated-by: Claude Code (Opus 5.5)
…hange Claude Sonnet 5.5 ran the 46 security-issue-sync cases whose prompts include text the cve-tool change rewrote (step 5b, the pointer-routing pair, 1d, 2b, 2c and the hand-off delegate), against c22d576. 44 passed on the first run. Of the other three in step 2b, case-2 and case-6 pass on a re-run (a self-contradicting grader verdict, and a `claude` CLI exit before any output). case-5 failed both times on a grader "NO" whose reason says the candidate matches; on main (8d24b52) the same case failed once and passed once, and the branch changes no text it depends on (distributor pre-announcement). Refs apache#1800 Generated-by: Claude Code (Opus 5.5)
Kaap10
left a comment
There was a problem hiding this comment.
This cleanly implements the tool-neutral CVE interface behind cve-tool and decouples the security skills and helpers from hardcoded Vulnogram states (REVIEW, READY) into generic contract states (review-ready, publish-ready). The config resolution reuse through release-config's reader is solid, and dual-tree parity between tools/ and plugins/magpie-security-tools is fully maintained.
A few technical observations on edge-case behavior and command construction below.
Smaller observations
- See inline comments on
tools/cve-tool/src/cve_tool/adapters/vulnogram.py:99,tools/cve-tool/src/cve_tool/cli.py:114, andtools/security-management/src/security_management/shared/cve_tool.py:38.
| code = main(argv) | ||
| finally: | ||
| text = captured.getvalue() | ||
| if code == 0 and text.strip(): |
There was a problem hiding this comment.
Edge-case handling on unexpected stdout text
vulnogram-api-record-fetch --state-only prints the raw state string on stdout. If vulnogram_api.record_fetch.main() exits 0 but prints warning lines or logging along with the state (e.g. UserWarning: ...\nREVIEW), text.strip() evaluates to the entire multi-line string. In that case, generic_state(text.strip()) falls back to "unknown", which will cause push.py's verify step to fail closed and block handoff (fetched == 'unknown').
Consider extracting text.strip().splitlines()[-1] or testing that generic_state() can handle leading warning lines, so transient warnings on a successful fetch don't misclassify a valid state as unknown.
There was a problem hiding this comment.
Thanks for checking this. After running some tests, on the --state-only path, record_fetch writes only the state to stdout. Its diagnostics go to stderr, as Python warnings and logging do by default, and stderr is not captured, so a successful fetch prints exactly one line.
If that ever changes, the result is unknown and push's verify step stops and hands off. That is the fail-closed outcome we want, so I'd rather keep it than read the last line, which would trust whatever happened to be printed last.
There was a problem hiding this comment.
Makes total sense - agree that fail-closed is the right posture here rather than guessing. Thanks for clarifying!
| f"the {resolved.tool} adapter does not implement {args.operation}" | ||
| + (f": {hint}" if hint else ""), | ||
| ) | ||
| code: int = adapter.run(args.operation, list(args.args)) |
There was a problem hiding this comment.
Pass-through options ordering with argparse.REMAINDER
Because args uses nargs=argparse.REMAINDER, any flags intended for cve-tool itself (like --project-root or --config-dir) that appear after the operation name are treated as adapter arguments rather than cve-tool arguments.
For example, cve-tool fetch --project-root <dir> --cve-id <id> will pass --project-root directly to vulnogram_api.record_fetch, which does not recognize the flag and exits 2. This is documented in the docstring ("Everything after the operation goes to the adapter unchanged"), but it might be worth mentioning in tools/cve-tool/README.md under usage so callers know global options must precede <operation>.
There was a problem hiding this comment.
Thanks for catching it. Rather than only documenting it, cve-tool now refuses --tool, --project-root and --config-dir after the operation with a usage error (exit 2) that names the option, before the adapter runs. That also fixes cve-tool which --project-root DIR, which used to ignore DIR. The README documents the order and reserves the three names for adapters.
| def command(data: Mapping[str, Any], operation: str, *args: str) -> str: | ||
| """`uv run --project <cve-tool> cve-tool <operation> <args>`; `data["cve_tool_project"]` overrides the path.""" | ||
| project = str(data.get("cve_tool_project") or PROJECT) | ||
| return " ".join(("uv run --project", w(project), "cve-tool", operation, *args)) |
There was a problem hiding this comment.
Shell-quoting expectation for *args
w(project) correctly quotes the project path, but *args is joined raw with spaces. Callers in push.py properly quote their arguments before passing (e.g., w(cve_id) and w(json_file)), but if future callers pass unquoted paths with spaces (e.g. --json-file path with spaces.json), the generated bash command will split unexpectedly.
Either adding w(a) mapping over args or keeping an explicit type annotation/docstring note that args must already be shell-quoted will prevent bugs when new cve-tool operations are wired in.
There was a problem hiding this comment.
Agreed, thanks. command() now quotes every argument itself, and the callers pass raw values. It uses shlex.quote rather than w(), because the allocation title comes from the tracker and w() leaves a <placeholder>-shaped value bare.
cve_tool.command() now shell-quotes every argument itself, so callers pass raw values. A new caller can no longer print a broken command by forgetting to quote a path with a space. It uses shlex.quote rather than w(): the allocation title comes from the tracker, and w() leaves a <placeholder>-shaped value bare. The only visible change is that the unset-PMC fallback now prints '<pmc>' quoted. Generated-by: Claude Code (Opus 5.5)
Everything after the operation goes to the adapter, so `cve-tool fetch --project-root DIR ...` handed --project-root to the adapter, which rejected it with a vague "unrecognized arguments". And `cve-tool which --project-root DIR` ignored DIR without saying so. cve-tool now refuses --tool, --project-root and --config-dir after the operation with a usage error (exit 2) naming the option, before the adapter runs. The README and the cve-tooling spec document the order and reserve the three names for adapters. Generated-by: Claude Code (Opus 5.5)
Summary
security-managementhelpers ran Vulnogram's own commands (vulnogram-api-record-update,-record-fetch, …) and made decisions on Vulnogram's state names (state == REVIEW).tools/cve-tool/README.mdalready defined a tool-neutral interface, and the organization config already names the tool (cve_authority.tool), but no code implemented the interface or read the setting. A project on another CVE system had no way to plug in.cve-toolcommand now implements that interface. It readscve_authority.tool, hands each call to that tool's adapter, and reports record states with the generic names (allocated,review-ready,publish-ready,public). Vulnogram is the only adapter and the ASF implementation; it calls the existing Vulnogram code unchanged. The helpers and skills now runcve-tool <operation>instead of Vulnogram's commands.Closes #1800
Background
A CVE record goes through allocate → push updates → check state → publish. Two layers drive those steps:
security-issue-sync,security-cve-allocate,security-issue-invalidate,security-issue-deduplicate): Markdown instructions the agent follows;security-managementhelpers: Python that decides the next step and prints the exact command for the agent to run.Before this PR, both named Vulnogram's commands directly, for example:
tools/security-management/src/security_management/issue_sync/push.pyhardcoded<framework>/tools/cve-tool-vulnogram/oauth-apiand printedvulnogram-api-record-update …;skills/security-issue-sync/release-manager-comments.mdgated the release-manager hand-off onstate == REVIEW;skills/security-issue-sync/cve-record-comments.mdpointed the reviewer-comment fetch attools/<cve-tool>/oauth-api, a path that does not exist.What changes
The PR has five commits, meant to be reviewed in order:
feat(cve-tool): add the cve-tool command…tools/cve-tool/package.cve-tool [--tool NAME] <allocate|fetch|push|publish|retract|check|setup|which> [args…]resolvescve_authority.tool(--tool, thenproject.md, then the organization manifest, reusingrelease-config's reader) and passes the arguments to the adapter unchanged. The Vulnogram adapter calls the existingvulnogram_api.*.main()functions and translates states at the edge:fetch --state-onlyprintsreview-ready, notREVIEW, andpublish --allow-stateaccepts generic states.refactor(security-management): print cve-tool commands…cve-tool …commands, built in one place (shared/cve_tool.py). The post-push verify reads both the generic and the native state.docs(security): run CVE-record steps through cve-tool…issue-syncandcve-allocateruncve-tooland use the generic states. This is required, not cosmetic:fetchnow printsreview-ready, so a check written againstREVIEWwould no longer match. Vulnogram names stay only as ASF examples. The two step-5b eval fixtures follow the new commands.docs(cve-tool): document the cve-tool command…issue-deduplicateandissue-invalidategive a concretecve-toolcommand where they named a contract method abstractly. Thelabels-and-capabilitiesrow and thecve-toolingspec are updated.test(evals): record the cross-model issue-sync run…How the operations map:
cve-toolallocateallocatevulnogram-api-allocatefetchfetch_current_statevulnogram-api-record-fetchpushpush_updatevulnogram-api-record-updatepublishpublishvulnogram-api-record-publishretractretract22, hint: reject in the Vulnogram UI)check/setupvulnogram-api-check/vulnogram-api-setupThe interface adds three exit codes of its own, clear of Vulnogram's
0–7:20no tool declared,21the tool has no adapter,22the adapter lacks the operation.What does not change
tools/cve-tool-vulnogram/oauth-api) is not modified.generate-cve-json) still emits Vulnogram-shaped JSON, including the nativeCNA_private.state. Making it tool-neutral is a separate step.tools/cve-tool-vulnogram/(credit policies, templates) stay: they are documents the skills read, not commands they run.Type of change
.claude/skills/<name>/) — eval fixtures updated belowtools/<system>/*.md)tools/*/withpyproject.toml)docs/,README.md,CONTRIBUTING.md)projects/_template/)prek, workflows, validators)Test plan
prek run --all-filespassesprek run --all-files --skip workspace-pytest, thenprek run workspace-pytest --from-ref origin/main --to-ref HEAD. The rootpyproject.toml/uv.lockchange makes the second one select all 54 workspace members. Both pass.check-claude-mods-localfails onICON_MISSINGfor the root.claude-plugin/plugin.jsonwith the locally installed Claude Code 2.1.323. This branch touches no file that hook watches.uv run pytest/ruff check/mypypassestools/cve-tool(new): 39 tests covering config lookup, dispatch and argument pass-through, exit-code pass-through, the 20/21/22 refusals, state mapping both ways, and every operation importing a realvulnogram_apientry point.tools/security-management: 957 tests, with the expected commands updated and 3 new tests (generic fetched state,cve_tool_projectpath override).tools/cve-tool-vulnogram/oauth-api: existing tests pass unchanged.(
PYTHONPATH=tools/skill-evals/src python3 -m skill_evals.runner tools/skill-evals/evals/<skill>/)Cross-model run (Claude Sonnet 5.5 via
claude -p) on the 46security-issue-synccases whose prompts include rewritten text, journaled intools/skill-evals/evals/security-issue-sync/runs.jsonl:step-5b-judged-gatespointer-routingpointer-routing-gatherstep-1d-signalsstep-2b-proposed-changesstep-2c-next-stepstep-handoff-delegatestep-2b/case-5-pre-announce-distributorsfailed both runs on a grader "NO" whose stated reason says the candidate matches on the decision. Onmain(8d24b524) the same case failed once and passed once, and this branch changes no text it depends on (distributor pre-announcement).case-2(a self-contradicting grader verdict) andcase-6(theclaudeCLI exiting before any output) passed on re-run.Agent self-eval of
step-5b-judged-gates: 2/2 match.(a regression test for the bug fixed / the behaviour added — see CONTRIBUTING.md)
step-5b-judged-gatescase-4 and case-5 now show thecve-tool check/push/fetch --state-onlycommands and the genericreview-readystate the skill sees after this change.cve-tool --tool vulnogram check --credentials <missing file>→not-configured, exit2(Vulnogram's own);cve-tool --tool vulnogram fetch --cve-id not-a-cve --state-only→ Vulnogram's own refusal, exit1;cve-tool whichresolvesvulnogramfor an ASF project andmitre-formfor an independent one;mitre-formthen exits21, andretractexits22.security-management/src(grep forvulnogram-api-; only adapter-doc links remain).skill-token-count --writerestampedsecurity-cve-allocate,security-issue-deduplicateandsecurity-issue-invalidate.cve-toolingspec gains acceptance criterion 10 for the command, and its validation block runs the new tests.RFC-AI-0004 compliance
<PROJECT>,<tracker>,<upstream>,<security-list>) used in all skill / tool prose (thecheck-placeholdersprek hook is the mechanical gate)Linked issues
Closes #1800
Generated files, safe to skip:
plugins/magpie-security-tools/tools/**(the mirrorcheck-family-plugins.py --fixwrites),uv.lock, themeasured_tokens:stamps, and the eval run-history table. Hand-written diff: 41 files, +1041 / −193.Arguments pass through unchanged, so adapter-specific flags (
--pmc,--comments-only) still appear in the skills. A tool-neutral flag set can come later without touching the skills' structure.One behaviour change outside the ASF. A project inheriting
mitre-form(theindependentdefault) used to be handed Vulnogram commands that could not work for it. It now gets exit21, and the skill takes its manual path.cve_allocate/render.pynow offers the source-tab paste fallback wheneversource_tab_url_templateis set, rather than only for Vulnogram. Only the Vulnogram config sets that template, so the output is the same today.tools/cve-tooldepends onrelease-configto reuse its project → organization config reader rather than add a second parser. Both are stdlib-only.Known follow-ups, out of scope:
retract.generate-cve-jsonis still Vulnogram-shaped.Bash(vulnogram-api-record-fetch *)inpermission-auditandsetup verifymatches neither the old nor the newuv run …spelling the skills use.The first and third are recorded under Known gaps in
tools/spec-loop/specs/cve-tooling.md.Was generative AI tooling used to co-author this PR?
Generated-by: Claude Code (Opus 5.5)
🤖 Generated with Claude Code