Skip to content

feat(cve-tool): run CVE-record operations through the configured CVE tool - #2062

Open
sjyangkevin wants to merge 7 commits into
apache:mainfrom
sjyangkevin:feat/cve-tool-interface
Open

sjyangkevin wants to merge 7 commits into
apache:mainfrom
sjyangkevin:feat/cve-tool-interface

Conversation

@sjyangkevin

Copy link
Copy Markdown
Contributor

Summary

  • Why. The security skills and the security-management helpers 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.md already 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.
  • What. A small cve-tool command now implements that interface. It reads cve_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 run cve-tool <operation> instead of Vulnogram's commands.
  • For ASF projects nothing changes: the same Vulnogram calls run underneath, with the same flags, output and exit codes.

Closes #1800

Background

A CVE record goes through allocate → push updates → check state → publish. Two layers drive those steps:

  • the skills (security-issue-sync, security-cve-allocate, security-issue-invalidate, security-issue-deduplicate): Markdown instructions the agent follows;
  • the security-management helpers: 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.py hardcoded <framework>/tools/cve-tool-vulnogram/oauth-api and printed vulnogram-api-record-update …;
  • skills/security-issue-sync/release-manager-comments.md gated the release-manager hand-off on state == REVIEW;
  • skills/security-issue-sync/cve-record-comments.md pointed the reviewer-comment fetch at tools/<cve-tool>/oauth-api, a path that does not exist.

What changes

The PR has five commits, meant to be reviewed in order:

# Commit What it does
1 feat(cve-tool): add the cve-tool command… New tools/cve-tool/ package. cve-tool [--tool NAME] <allocate|fetch|push|publish|retract|check|setup|which> [args…] resolves cve_authority.tool (--tool, then project.md, then the organization manifest, reusing release-config's reader) and passes the arguments to the adapter unchanged. The Vulnogram adapter calls the existing vulnogram_api.*.main() functions and translates states at the edge: fetch --state-only prints review-ready, not REVIEW, and publish --allow-state accepts generic states.
2 refactor(security-management): print cve-tool commands… The helpers print cve-tool … commands, built in one place (shared/cve_tool.py). The post-push verify reads both the generic and the native state.
3 docs(security): run CVE-record steps through cve-tool… issue-sync and cve-allocate run cve-tool and use the generic states. This is required, not cosmetic: fetch now prints review-ready, so a check written against REVIEW would no longer match. Vulnogram names stay only as ASF examples. The two step-5b eval fixtures follow the new commands.
4 docs(cve-tool): document the cve-tool command… The contract README documents the command (operations, exit codes, adding an adapter), drops the stale "PR4 rename" notes, and corrects the consumer list. issue-deduplicate and issue-invalidate give a concrete cve-tool command where they named a contract method abstractly. The labels-and-capabilities row and the cve-tooling spec are updated.
5 test(evals): record the cross-model issue-sync run… The journaled eval run and its generated run-history table.

How the operations map:

cve-tool Contract method Vulnogram adapter runs
allocate allocate vulnogram-api-allocate
fetch fetch_current_state vulnogram-api-record-fetch
push push_update vulnogram-api-record-update
publish publish vulnogram-api-record-publish
retract retract not implemented (exit 22, hint: reject in the Vulnogram UI)
check / setup session probe / setup vulnogram-api-check / vulnogram-api-setup

The interface adds three exit codes of its own, clear of Vulnogram's 0–7: 20 no tool declared, 21 the tool has no adapter, 22 the adapter lacks the operation.

What does not change

  • ASF behaviour. Same Vulnogram calls, flags, output and exit codes underneath.
  • The Vulnogram code (tools/cve-tool-vulnogram/oauth-api) is not modified.
  • The CVE JSON generator (generate-cve-json) still emits Vulnogram-shaped JSON, including the native CNA_private.state. Making it tool-neutral is a separate step.
  • Links to documents in tools/cve-tool-vulnogram/ (credit policies, templates) stay: they are documents the skills read, not commands they run.
  • No new adapters (GHSA, MITRE form, CVE.org direct). This PR creates the slot they plug into.

Type of change

  • Skill change (.claude/skills/<name>/) — eval fixtures updated below
  • Tool / bridge contract (tools/<system>/*.md)
  • Python package (tools/*/ with pyproject.toml)
  • Groovy reference impl
  • Cross-cutting (RFC, AGENTS.md, sandbox, privacy-LLM)
  • Documentation (docs/, README.md, CONTRIBUTING.md)
  • Project template (projects/_template/)
  • CI / dev loop (prek, workflows, validators)
  • Other:

Test plan

  • prek run --all-files passes
    • Run as the two pre-PR gates in AGENTS.md: prek run --all-files --skip workspace-pytest, then prek run workspace-pytest --from-ref origin/main --to-ref HEAD. The root pyproject.toml / uv.lock change makes the second one select all 54 workspace members. Both pass.
    • One local-only exception: check-claude-mods-local fails on ICON_MISSING for the root .claude-plugin/plugin.json with the locally installed Claude Code 2.1.323. This branch touches no file that hook watches.
  • For Python packages touched: uv run pytest / ruff check / mypy passes
    • tools/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 real vulnogram_api entry point.
    • tools/security-management: 957 tests, with the expected commands updated and 3 new tests (generic fetched state, cve_tool_project path override).
    • tools/cve-tool-vulnogram/oauth-api: existing tests pass unchanged.
  • For Groovy bridges touched: command-line invocation tested end-to-end
  • For skill changes: eval suite passes for the affected skill
    (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 46 security-issue-sync cases whose prompts include rewritten text, journaled in tools/skill-evals/evals/security-issue-sync/runs.jsonl:

      Step Cases Result
      step-5b-judged-gates 2 2 pass
      pointer-routing 10 10 pass
      pointer-routing-gather 7 7 pass
      step-1d-signals 7 7 pass
      step-2b-proposed-changes 12 11 pass (2 of them on re-run), 1 grader flake (below)
      step-2c-next-step 4 4 pass, including the reworded Step 13 next step
      step-handoff-delegate 4 4 pass
    • step-2b/case-5-pre-announce-distributors failed both runs on a grader "NO" whose stated reason says the candidate matches on the decision. On main (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) and case-6 (the claude CLI exiting before any output) passed on re-run.

    • Agent self-eval of step-5b-judged-gates: 2/2 match.

  • For skill behaviour changes: a new or updated eval fixture is included in this PR
    (a regression test for the bug fixed / the behaviour added — see CONTRIBUTING.md)
    • step-5b-judged-gates case-4 and case-5 now show the cve-tool check / push / fetch --state-only commands and the generic review-ready state the skill sees after this change.
  • Other:
    • End to end against the real Vulnogram code, no network:
      • cve-tool --tool vulnogram check --credentials <missing file> → not-configured, exit 2 (Vulnogram's own);
      • cve-tool --tool vulnogram fetch --cve-id not-a-cve --state-only → Vulnogram's own refusal, exit 1;
      • cve-tool which resolves vulnogram for an ASF project and mitre-form for an independent one; mitre-form then exits 21, and retract exits 22.
    • No direct Vulnogram command call remains in the skills or in security-management/src (grep for vulnogram-api-; only adapter-doc links remain).
    • skill-token-count --write restamped security-cve-allocate, security-issue-deduplicate and security-issue-invalidate.
    • The cve-tooling spec gains acceptance criterion 10 for the command, and its validation block runs the new tests.

RFC-AI-0004 compliance

  • HITL — any new mutation is gated on explicit user confirmation
  • Sandbox — no new unrestricted host access; network reach declared in the adapter
  • Vendor neutrality — placeholders (<PROJECT>, <tracker>, <upstream>, <security-list>) used in all skill / tool prose (the check-placeholders prek hook is the mechanical gate)
  • Conversational + correctable — agentic-override path documented if behaviour is adopter-tunable
  • Write-access discipline — no autonomous outbound messages; drafts only, sent on confirmation
  • Privacy LLM — private content does not reach a non-approved LLM; redactor invoked where needed

Linked issues

Closes #1800

  • Generated files, safe to skip: plugins/magpie-security-tools/tools/** (the mirror check-family-plugins.py --fix writes), uv.lock, the measured_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 (the independent default) used to be handed Vulnogram commands that could not work for it. It now gets exit 21, and the skill takes its manual path.

  • cve_allocate/render.py now offers the source-tab paste fallback whenever source_tab_url_template is set, rather than only for Vulnogram. Only the Vulnogram config sets that template, so the output is the same today.

  • tools/cve-tool depends on release-config to reuse its project → organization config reader rather than add a second parser. Both are stdlib-only.

  • Known follow-ups, out of scope:

    • Vulnogram's adapter has no retract.
    • generate-cve-json is still Vulnogram-shaped.
    • The ASF allow-list recommendation Bash(vulnogram-api-record-fetch *) in permission-audit and setup verify matches neither the old nor the new uv 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?
  • Yes — Claude Code (Opus 5.5)

Generated-by: Claude Code (Opus 5.5)

🤖 Generated with Claude Code

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)
@github-actions github-actions Bot added family:security security-* skills family:tools tools/* family:docs Docs, MISSION.md, READMEs family:ci .github workflows, prek, validators capability:intake Import external signal + sync tracker state capability:resolve Close-out: invalidate, dedupe, CVE allocate, announcement contract:cve-authority Tool capability: CVE allocation / record management / publication substrate:analytics Tool substrate: read-only metrics / dashboards / renderers substrate:framework-dev Tool substrate: build / validate / eval the framework itself family:dev Repo dev: .asf.yaml, spec-loop specs, Node manifests, .claude/settings.json labels Nov 13, 2026
@sjyangkevin
sjyangkevin marked this pull request as ready for review November 13, 2026 05:25

@Kaap10 Kaap10 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, and tools/security-management/src/security_management/shared/cve_tool.py:38.

code = main(argv)
finally:
text = captured.getvalue()
if code == 0 and text.strip():

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
@sjyangkevin
sjyangkevin requested a review from Kaap10 November 13, 2026 15:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

capability:intake Import external signal + sync tracker state capability:resolve Close-out: invalidate, dedupe, CVE allocate, announcement contract:cve-authority Tool capability: CVE allocation / record management / publication family:ci .github workflows, prek, validators family:dev Repo dev: .asf.yaml, spec-loop specs, Node manifests, .claude/settings.json family:docs Docs, MISSION.md, READMEs family:security security-* skills family:tools tools/* substrate:analytics Tool substrate: read-only metrics / dashboards / renderers substrate:framework-dev Tool substrate: build / validate / eval the framework itself

Projects

None yet

Development

Successfully merging this pull request may close these issues.

issue-sync, cve-allocate and others call tools/cve-tool-vulnogram directly

2 participants