diff --git a/README.md b/README.md index ac76a336..b9ef7f9c 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ GitHits also exposes indexed package/source tools: | `docs_list` | Browse mixed package documentation pages | | `docs_read` | Read a documentation page by page ID | | `pkg_info` | Quick package triage: version, license, repository popularity, downloads, vulnerabilities | -| `pkg_vulns` | CVE / OSV advisories for a package or specific version | +| `pkg_vulns` | CVE / OSV advisories for a package or specific version, with filter echo and compact/verbose modes | | `pkg_deps` | Direct dependencies, dependency groups, and optional transitive graph | | `pkg_changelog` | Release notes / changelog entries for a package or GitHub repo | | `code_files` | Discover what files a dependency or repo contains | diff --git a/docs/implementation/cli-commands.md b/docs/implementation/cli-commands.md index 08bbca15..31318df7 100644 --- a/docs/implementation/cli-commands.md +++ b/docs/implementation/cli-commands.md @@ -144,19 +144,19 @@ githits pkg vulns crates:serde --json githits pkg vulns npm:minimatch --include-withdrawn --verbose ``` -Lists known CVE / OSV advisories for a package: severity, affected version ranges, fix versions, and upgrade targets. Malicious-package advisories (supply-chain attacks flagged by OSV) surface in a separate `MALWARE` bucket that sorts above all CVE advisories. +Lists known CVE / OSV advisories for a package: severity, affected version ranges, fix versions, and upgrade targets. Default text is capped at 5 advisory rows for readability; use `--verbose` for all rows or `--json` for the complete structured envelope. Malicious-package advisories (supply-chain attacks flagged by OSV) surface in a separate `MALWARE` bucket that sorts above all CVE advisories. **Package spec.** `:[@]`. Unlike `pkg info`, `pkg vulns` supports `@` so callers can inspect older pinned releases. Only `npm`, `pypi`, `hex`, and `crates` support vulnerability data; other registries are rejected client-side with `pkg vulns only supports npm, pypi, hex, and crates. Got: ${registry}.` -**Filtering.** `--severity low|medium|high|critical` maps to a CVSS float threshold (`low=0.1, medium=4, high=7, critical=9`) and is applied by the service. The returned `vulnerabilityCount` reflects the filtered set — no client-side filtering, no dual-summary block. Callers wanting the full picture omit the flag. `--include-withdrawn` includes retracted advisories; withdrawn advisories bucket below active ones in the terminal list. +**Filtering.** `--severity low|medium|high|critical` maps to a CVSS float threshold (`low=0.1, medium=4, high=7, critical=9`) and is applied by the service. The returned `vulnerabilityCount` reflects the filtered set — no client-side filtering, no dual-summary block. Callers wanting the full picture omit the flag. Active filters are echoed in text and under top-level JSON `filter`. `--include-withdrawn` includes retracted advisories; withdrawn advisories bucket below active ones in the terminal list. -**Zero-vulns hot path.** The common case (clean package) renders as header + one-line summary body (`No known vulnerabilities.`) — no breakdown, no advisory list, no footer. Agents checking "am I safe?" pay minimal token cost on the happy path. +**Zero-vulns hot path.** The common case (clean package) renders as header + one-line summary body (`No active vulnerabilities affect this version.`) — no breakdown, no advisory list, no footer. Filtered zero-results say `No vulnerabilities matching the filter affect this version.` so callers do not confuse a thresholded query with a clean package. **Version validation.** `pkg vulns` expects canonical package versions. Tag-style inputs such as `@v4.18.0` are rejected client-side with `INVALID_ARGUMENT` and an actionable message telling the caller to drop the leading `v`, instead of forwarding the request and surfacing an opaque upstream failure. -**Malware marker.** Advisories with `isMalicious: true` render with a red/bold `MALWARE` column (optionally combined as `MALWARE · crit` when both flags exist). Count surfaces in the summary breakdown line as `N MALWARE · N crit · …`. Buckets partition every returned advisory: `MALWARE + crit + high + medium + low + unrated = advisories.length`, which equals `summary.total` when the upstream count and list stay consistent. Non-malicious advisories without a CVSS score bucket under `unrated` so the breakdown reconciles with the header total (common for PyPI / Rust advisories where CVSS may be absent). +**Malware marker.** Advisories with `isMalicious: true` render with a red/bold `MALWARE` column (optionally combined as `MALWARE | crit` when both flags exist). Count surfaces in the summary breakdown line as `N MALWARE | N crit | ...`. Buckets partition every returned advisory: `MALWARE + crit + high + medium + low + unrated = advisories.length`, which equals `summary.total` when the upstream count and list stay consistent. Non-malicious advisories without a CVSS score bucket under `unrated` so the breakdown reconciles with the header total (common for PyPI / Rust advisories where CVSS may be absent). -**Affected-range truncation (terminal-width aware).** The `affected` detail row under each advisory caps at 4 ranges on narrow terminals (≤119 cols), 6 on standard-wide (120–159 cols), and 8 on ultrawide (≥160 cols). The remainder collapses into a dim `… (+N more; use -v)` hint. Verbose mode (`-v`) shows every range. JSON output is never truncated — machine consumers get the full list. +**Affected-range truncation (terminal-width aware).** The `affected` detail row under each advisory caps at 4 ranges on narrow terminals (≤119 cols), 6 on standard-wide (120–159 cols), and 8 on ultrawide (≥160 cols). The remainder collapses into a dim `... (+N more; use -v)` hint. Verbose mode (`-v`) shows every range. JSON output is never truncated — machine consumers get the full list. **Unrated severity column.** Advisories with no CVSS score (common on RUSTSEC / PYSEC upstreams) render with a dim `unrated` label in the severity column rather than an empty gutter, matching the header-breakdown vocabulary. They sort below banded advisories within the active bucket. @@ -164,7 +164,7 @@ Lists known CVE / OSV advisories for a package: severity, affected version range **Upgrade-path ordering.** `upgradePaths` are de-duplicated and sorted ascending by semver-ish comparison (pre-release suffixes rank below the matching base release), so the footer presents the minimum-churn upgrade first: `Upgrade options: 3.11.0, 4.0.0-rc1, 4.5.0, 4.19.2, …` rather than the backend's advisory-iteration order. -**Output envelope.** `{registry, name, version, requestedVersion?, summary: {total, affected?, bySeverity?}, advisories?, upgradePaths?}`. Each advisory: `{id?, aliases?, summary?, severity?, severityLabel?, affectedRanges?, fixedIn?, publishedAt?, modifiedAt?, withdrawnAt?, isMalicious?}`. `modifiedAt` included only when it differs from `publishedAt`. `isMalicious` included only when `true`. +**Output envelope.** `{registry, name, version, requestedVersion?, filter?, summary: {total, affected?, bySeverity?}, advisories?, upgradePaths?}`. `filter` echoes only explicit caller filters. Each advisory: `{id?, aliases?, summary?, severity?, severityLabel?, affectedRanges?, fixedIn?, publishedAt?, modifiedAt?, withdrawnAt?, isMalicious?}`. `modifiedAt` included only when it differs from `publishedAt`. `isMalicious` included only when `true`. **Exit codes.** 0 on success including zero-vulns; 1 on any error. Under `--json`, the error envelope is written to **stderr**. diff --git a/docs/implementation/tools.md b/docs/implementation/tools.md index 778781ed..90aa90aa 100644 --- a/docs/implementation/tools.md +++ b/docs/implementation/tools.md @@ -25,7 +25,7 @@ The CLI mirrors the production MCP tool contract where equivalent tools exist. C | `docs_list` | `registry`, `package_name`, `version?`, `limit?`, `after?`, `format?` | List hosted/crawled and repository-backed documentation pages for a package. Defaults to compact `text-v1` with ready-to-call `docs_read` follow-ups; repo-backed entries include exact source metadata for `code_read` follow-up when available. | | `docs_read` | `page_id`, `start_line?`, `end_line?`, `format?` | Read a documentation page by page ID. Defaults to `text-v1` with a 150-line MCP text cap; explicit line ranges are supported. `format: "json"` preserves full-document default while still honoring explicit ranges. Repo-backed pages include exact file follow-up metadata. | | `pkg_info` | `registry`, `package_name`, `verbose?`, `format?` | Latest-version package triage: license, description, repository popularity (stars/forks/issues and `[ARCHIVED]` when applicable), downloads, publish age, and vulnerability status. Example: `{registry:"npm", package_name:"express"}`. Set `verbose: true` for GitHub language/topics/last-pushed, recent advisories, and recent changes. Pass `format: "json"` for structured fields. | -| `pkg_vulns` | `registry`, `package_name`, `version?`, `min_severity?`, `include_withdrawn?`, `format?` | Known vulnerabilities for a package on npm, PyPI, Hex, or Crates. Defaults to compact summary text; pass `format: "json"` for per-advisory OSV ID + severity + affected/fix ranges, malware bucket, and upgrade paths. | +| `pkg_vulns` | `registry`, `package_name`, `version?`, `min_severity?`, `include_withdrawn?`, `verbose?`, `format?` | Known vulnerabilities for a package on npm, PyPI, Hex, or Crates. Example: `{registry:"npm", package_name:"lodash", version:"4.17.20", min_severity:"high"}`. Defaults to compact text capped at 5 advisory rows with active filter echo; set `verbose: true` to show all text rows or pass `format: "json"` for the complete per-advisory envelope. | | `pkg_deps` | `registry`, `package_name`, `version?`, `lifecycle?`, `include_transitive?`, `include_importers?`, `max_depth?`, `format?` | Direct runtime dependency list by default with resolved versions. Non-runtime groups are hidden with an MCP-native hint (`pass lifecycle="all"`). Use `lifecycle: "runtime"` for explicit runtime-only, a concrete non-runtime lifecycle for runtime plus matching groups, or `lifecycle: "all"` for all available groups. Optional transitive output includes aggregate edge counts, the preprocessed install footprint, typed conflicts and circular-dependency cycles; opt into importer provenance with `include_importers`. Pass `format: "json"` for the lean structured envelope. | | `pkg_changelog` | `registry?`, `package_name?`, `repo_url?`, `from_version?`, `to_version?`, `limit?`, `git_ref?`, `include_bodies?`, `format?` | Release notes or changelog entries for a package or GitHub repo. Defaults to compact text with newest-first entries and capped body previews; pass `format: "json"` for the structured envelope with full markdown bodies, or set `include_bodies: false` for a lean version / date / URL timeline. `from_version` switches to range mode (no count cap). Dual addressing (spec vs repo URL) is mutually exclusive. | | `code_files` | `target`, `path?`, `path_prefix?`, `globs?`, `extensions?`, `file_types?`, `languages?`, `file_intent?`, `file_intents?`, `exclude_file_intents?`, `exclude_doc_files?`, `exclude_test_files?`, `include_hidden?`, `limit?`, `wait_timeout_ms?`, `format?` | List files in an indexed dependency. Returns `{total, hasMore, files: [{path, name, language, fileType, byteSize}], resolution, indexedVersion}` in JSON mode. Dual addressing via `target.registry + target.package_name` (spec) or `target.repo_url + target.git_ref` (repo). Selectors (`path`, `path_prefix`, `globs`) are OR-ed; the other filters intersect on top. Emits an `INDEXING` error envelope when the dependency is being indexed on-demand — retry with a longer `wait_timeout_ms` or pick a version from `details.availableVersions`. `format` defaults to `text-v1` (paths-only listing); pass `format: "json"` for the structured envelope. | @@ -52,7 +52,9 @@ The CLI mirrors the production MCP tool contract where equivalent tools exist. C ### `pkg_vulns` response shape -**Filter-aware summary.** `min_severity` and `include_withdrawn` are passed straight through to the service. The returned `vulnerabilityCount` reflects the filtered set — there is no client-side filtering and no `summary.filtered` dual-block. Callers wanting the unfiltered view omit the flag. +**Filter-aware summary.** `min_severity` and `include_withdrawn` are passed straight through to the service. The returned `vulnerabilityCount` reflects the filtered set — there is no client-side filtering and no `summary.filtered` dual-block. Callers wanting the unfiltered view omit the flag. Explicit filters are echoed as top-level `filter` in JSON (`{minSeverity?, includeWithdrawn?: true}`) and as `Filter` lines in text. Defaults and explicit `include_withdrawn:false` do not echo. + +**Compact text vs verbose/JSON.** Default text caps the advisory list at 5 rendered rows and appends a surface-native hint (`use -v` on CLI, `use verbose=true or format=json` on MCP). Hidden-advisory counts are derived from the rendered advisory array, not backend summary counts. `--verbose` / `verbose:true` shows all advisory rows and full detail rows. JSON is never capped and ignores `verbose`. **Partitioning buckets.** Advisories with `isMalicious: true` count **only** under `summary.bySeverity.malware`; severity bands (`critical`/`high`/`medium`/`low`) count non-malicious advisories with a positive CVSS score; non-malicious advisories with no score count under `summary.bySeverity.unrated`. Every returned advisory lands in exactly one bucket — the client-side guarantee is `MALWARE + crit + high + medium + low + unrated = advisories.length`. The sum also equals `summary.total`, which is re-derived from the deduped advisory list (see below). The malware bucket sorts to the top of the advisory list regardless of score. The `unrated` bucket ensures the terminal breakdown line reconciles with the header total on Rust / PyPI packages where a non-trivial fraction of advisories ship without a CVSS score. @@ -66,7 +68,7 @@ The CLI mirrors the production MCP tool contract where equivalent tools exist. C **Registry coverage.** Only npm, PyPI, Hex, and Crates have vulnerability data. The CLI + MCP reject the other registries client-side with a tool-specific message (`pkg vulns only supports npm, pypi, hex, and crates. Got: ${registry}.`) — rejection predicate lives in `src/shared/package-vulnerabilities-request.ts` rather than the shared registry module, since it is a tool-specific capability matrix. -`pkg_vulns` shares its envelope builder and text formatter with the CLI `githits pkg vulns` command via `src/shared/package-vulnerabilities-request.ts` and `src/shared/package-vulnerabilities-response.ts`. MCP defaults to compact text and uses `format: "json"` for structured output. The parity test (`src/tools/package-vulnerabilities-parity.test.ts`) passes `format: "json"`, asserts `toEqual` across the service-sourced success and typed-error fixtures, and uses `toMatchObject` for builder-sourced `INVALID_ARGUMENT` fixtures such as unsupported registries and tag-style `v`-prefixed versions. +`pkg_vulns` shares its envelope builder and text formatter with the CLI `githits pkg vulns` command via `src/shared/package-vulnerabilities-request.ts` and `src/shared/package-vulnerabilities-response.ts`. MCP defaults to compact text and uses `format: "json"` for structured output. The shared text formatter is surface-aware so MCP hints never mention CLI flags. The parity test (`src/tools/package-vulnerabilities-parity.test.ts`) passes `format: "json"`, asserts `toEqual` across the service-sourced success/filter/typed-error fixtures, and uses `toMatchObject` for builder-sourced `INVALID_ARGUMENT` fixtures such as unsupported registries and tag-style `v`-prefixed versions. ### `pkg_deps` response shape diff --git a/eval/agentic/workloads/package-vulnerability-filter.md b/eval/agentic/workloads/package-vulnerability-filter.md new file mode 100644 index 00000000..11fb75b5 --- /dev/null +++ b/eval/agentic/workloads/package-vulnerability-filter.md @@ -0,0 +1,6 @@ +# Workload: Package Vulnerability Filter Check + +You are evaluating whether `npm:lodash@4.17.20` is safe to keep pinned in a +production Node.js service. Summarize the active vulnerability risk for that +specific version, focusing on high-or-worse advisories. Include whether there +are fix versions or upgrade guidance, and call out any caveats in the evidence. diff --git a/scripts/cli-smoke.ts b/scripts/cli-smoke.ts index f37d2e54..f3d6d06e 100644 --- a/scripts/cli-smoke.ts +++ b/scripts/cli-smoke.ts @@ -467,6 +467,15 @@ async function runLiveSmoke(): Promise { "pkg vulns terminal missing context", ); + const filteredVulnsText = assertTerminalOutput( + await runCli(["pkg", "vulns", "npm:lodash@4.17.20", "--severity", "high"]), + "pkg vulns filtered terminal", + ); + assert( + filteredVulnsText.includes("Filter severity >= high"), + "pkg vulns filtered terminal missing filter echo", + ); + const vulnsJson = assertJsonOutput( await runCli(["pkg", "vulns", "npm:express", "--json"]), "pkg vulns json", @@ -477,6 +486,24 @@ async function runLiveSmoke(): Promise { "pkg vulns json missing vulnerability data", ); + const filteredVulnsJson = assertJsonOutput( + await runCli([ + "pkg", + "vulns", + "npm:lodash@4.17.20", + "--severity", + "high", + "--json", + ]), + "pkg vulns filtered json", + ); + assertRecord(filteredVulnsJson, "pkg vulns filtered json"); + assertRecord(filteredVulnsJson.filter, "pkg vulns filtered json filter"); + assert( + filteredVulnsJson.filter.minSeverity === "high", + "pkg vulns filtered json missing severity filter echo", + ); + const changelogText = assertTerminalOutput( await runCli(["pkg", "changelog", "npm:express", "--limit", "1"]), "pkg changelog terminal", diff --git a/scripts/mcp-smoke.ts b/scripts/mcp-smoke.ts index 6079c318..9454692a 100644 --- a/scripts/mcp-smoke.ts +++ b/scripts/mcp-smoke.ts @@ -361,6 +361,25 @@ async function runLiveSmoke(client: Client): Promise { vulnsText.includes("express") || vulnsText.includes("vulnerab"), "pkg_vulns default missing context", ); + assert(!vulnsText.includes("use -v"), "pkg_vulns leaked CLI -v hint"); + + const filteredVulnsText = assertDefaultText( + await callTool(client, "pkg_vulns", { + registry: "npm", + package_name: "lodash", + version: "4.17.20", + min_severity: "high", + }), + "pkg_vulns filtered default", + ); + assert( + filteredVulnsText.includes("Filter severity >= high"), + "pkg_vulns filtered default missing filter echo", + ); + assert( + !filteredVulnsText.includes("use -v"), + "pkg_vulns filtered default leaked CLI -v hint", + ); const vulnsJson = assertJsonResult( await callTool(client, "pkg_vulns", { @@ -376,6 +395,23 @@ async function runLiveSmoke(client: Client): Promise { "pkg_vulns json missing vulnerability data", ); + const filteredVulnsJson = assertJsonResult( + await callTool(client, "pkg_vulns", { + registry: "npm", + package_name: "lodash", + version: "4.17.20", + min_severity: "high", + format: "json", + }), + "pkg_vulns filtered json", + ); + assertRecord(filteredVulnsJson, "pkg_vulns filtered json"); + assertRecord(filteredVulnsJson.filter, "pkg_vulns filtered json filter"); + assert( + filteredVulnsJson.filter.minSeverity === "high", + "pkg_vulns filtered json missing severity filter echo", + ); + const changelogText = assertDefaultText( await callTool(client, "pkg_changelog", { registry: "npm", diff --git a/src/commands/mcp-instructions.ts b/src/commands/mcp-instructions.ts index 84e5253c..e089d5ce 100644 --- a/src/commands/mcp-instructions.ts +++ b/src/commands/mcp-instructions.ts @@ -54,7 +54,7 @@ const PKG_INFO_BULLET = '- `pkg_info` — latest-version package triage by `registry` + `package_name` (e.g. `npm` + `express`): license, repository popularity, downloads, publish age, and vulnerability status. Set `verbose: true` for GitHub language/topics/last-pushed, recent advisories, and recent changes; pass `format: "json"` for structured fields.'; const PKG_VULNS_BULLET = - '- `pkg_vulns` — compact known CVE / OSV advisory summary for npm, PyPI, Hex, or Crates packages, optionally pinned to `version`. Filter with `min_severity`; include retracted advisories with `include_withdrawn`. Pass `format: "json"` for per-advisory structured fields.'; + '- `pkg_vulns` — compact known CVE / OSV advisory summary for npm, PyPI, Hex, or Crates packages, optionally pinned to `version` (e.g. `npm` + `lodash` + `4.17.20`). Filter with `min_severity`; include retracted advisories with `include_withdrawn`. Default text is capped; use `verbose: true` for all rows or `format: "json"` for the complete per-advisory envelope.'; const PKG_DEPS_BULLET = '- `pkg_deps` — compact direct runtime deps by default. Use `lifecycle: "runtime"` for explicit runtime-only, a concrete lifecycle for runtime plus matching non-runtime deps, or `lifecycle: "all"` for all groups. Use `include_transitive` for the full graph and `include_importers` for provenance. Pass `format: "json"` for the structured envelope.'; diff --git a/src/commands/pkg/vulns.test.ts b/src/commands/pkg/vulns.test.ts index 21a492fd..abb430f7 100644 --- a/src/commands/pkg/vulns.test.ts +++ b/src/commands/pkg/vulns.test.ts @@ -39,10 +39,30 @@ describe("pkgVulnsAction", () => { await pkgVulnsAction("npm:express", {}, createDeps()); const combined = writes.join(""); - expect(combined).toContain("express @ 4.18.0 · npm"); + expect(combined).toContain("express @ 4.18.0 | npm"); expect(combined).toContain("6 vulnerabilities affect this version"); expect(combined).toContain("MALWARE"); expect(combined).toContain("Fix version: 4.18.2."); + expect(combined).toContain("... (+1 more; use -v)"); + writeSpy.mockRestore(); + }); + + it("--verbose renders all advisory rows", async () => { + const writes: string[] = []; + const writeSpy = spyOn(process.stdout, "write").mockImplementation((( + chunk: string | Uint8Array, + ) => { + writes.push( + typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk), + ); + return true; + }) as typeof process.stdout.write); + + await pkgVulnsAction("npm:express", { verbose: true }, createDeps()); + + const combined = writes.join(""); + expect(combined).toContain("GHSA-nnnn-nnnn-nnnn"); + expect(combined).not.toContain("... (+1 more; use -v)"); writeSpy.mockRestore(); }); @@ -59,6 +79,58 @@ describe("pkgVulnsAction", () => { logSpy.mockRestore(); }); + it("--json --verbose matches --json", async () => { + const logSpy = spyOn(console, "log").mockImplementation(() => {}); + + await pkgVulnsAction("npm:express", { json: true }, createDeps()); + await pkgVulnsAction( + "npm:express", + { json: true, verbose: true }, + createDeps(), + ); + + expect(JSON.parse(logSpy.mock.calls[1]?.[0] as string)).toEqual( + JSON.parse(logSpy.mock.calls[0]?.[0] as string), + ); + logSpy.mockRestore(); + }); + + it("echoes filters in terminal text and JSON", async () => { + const writes: string[] = []; + const writeSpy = spyOn(process.stdout, "write").mockImplementation((( + chunk: string | Uint8Array, + ) => { + writes.push( + typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk), + ); + return true; + }) as typeof process.stdout.write); + + await pkgVulnsAction( + "npm:express", + { severity: "HIGH", includeWithdrawn: true }, + createDeps(), + ); + + const combined = writes.join(""); + expect(combined).toContain("Filter severity >= high"); + expect(combined).toContain("Filter include withdrawn"); + writeSpy.mockRestore(); + + const logSpy = spyOn(console, "log").mockImplementation(() => {}); + await pkgVulnsAction( + "npm:express", + { severity: "HIGH", includeWithdrawn: true, json: true }, + createDeps(), + ); + const payload = JSON.parse(logSpy.mock.calls[0]?.[0] as string); + expect(payload.filter).toEqual({ + minSeverity: "high", + includeWithdrawn: true, + }); + logSpy.mockRestore(); + }); + it("passes version from spec to the service", async () => { const packageVulnerabilities = mock(() => Promise.resolve(defaultVulnerabilityReport), diff --git a/src/commands/pkg/vulns.ts b/src/commands/pkg/vulns.ts index f908012d..d6200f90 100644 --- a/src/commands/pkg/vulns.ts +++ b/src/commands/pkg/vulns.ts @@ -51,7 +51,7 @@ export async function pkgVulnsAction( } const parsed = parsePackageSpec(spec); - const { params } = buildPackageVulnerabilitiesParams({ + const { params, filter } = buildPackageVulnerabilitiesParams({ registry: parsed.registry, packageName: parsed.name, version: parsed.version, @@ -64,6 +64,7 @@ export async function pkgVulnsAction( if (options.json) { const payload = buildPackageVulnerabilitiesSuccessPayload(report, { requestedVersion: parsed.version, + filter, }); console.log(JSON.stringify(payload)); return; @@ -73,6 +74,8 @@ export async function pkgVulnsAction( verbose: options.verbose, useColors: shouldUseColors(), requestedVersion: parsed.version, + filter, + surface: "cli", terminalWidth: process.stdout.columns, }); process.stdout.write(output); @@ -135,22 +138,25 @@ function formatVulnsTerminalError(mapped: MappedError): string { if (available && available.length > 0) { const sample = available.slice(0, 5).join(", "); const more = available.length - 5; - const suffix = more > 0 ? `, … (+${more} more)` : ""; + const suffix = more > 0 ? `, ... (+${more} more)` : ""; lines.push(` available: ${sample}${suffix}`); } return lines.join("\n"); } const PKG_VULNS_DESCRIPTION = `Show known vulnerabilities for a package. Lists CVE / OSV advisories -with severity, affected version ranges, and fix versions. -Malicious-package advisories are flagged prominently. +with severity, affected version ranges, and fix versions. Default text is +capped for readability; use --verbose for all advisory rows or --json for +the complete structured envelope. Package spec: :[@]. Supported registries: npm, pypi, hex, crates. Omit @ to check the latest release. +Example: githits pkg vulns npm:lodash@4.17.20 --severity high Severity filter (--severity) and withdrawn-advisory visibility (--include-withdrawn) are passed through to the backend; the -returned count reflects whatever survived the filter.`; +returned count reflects whatever survived the filter and active filters +are echoed in text and JSON output.`; export function registerPkgVulnsCommand(pkgCommand: Command): Command { return pkgCommand diff --git a/src/shared/package-vulnerabilities-request.test.ts b/src/shared/package-vulnerabilities-request.test.ts index 97421cfe..ce64d18d 100644 --- a/src/shared/package-vulnerabilities-request.test.ts +++ b/src/shared/package-vulnerabilities-request.test.ts @@ -105,6 +105,32 @@ describe("buildPackageVulnerabilitiesParams", () => { ).toBe(7.0); }); + it("returns explicit filter metadata with canonical severity labels", () => { + const { filter } = buildPackageVulnerabilitiesParams({ + registry: "npm", + packageName: "express", + minSeverity: " High ", + includeWithdrawn: true, + }); + expect(filter).toEqual({ minSeverity: "high", includeWithdrawn: true }); + }); + + it("does not echo omitted or false filters", () => { + expect( + buildPackageVulnerabilitiesParams({ + registry: "npm", + packageName: "express", + }).filter, + ).toBeUndefined(); + expect( + buildPackageVulnerabilitiesParams({ + registry: "npm", + packageName: "express", + includeWithdrawn: false, + }).filter, + ).toBeUndefined(); + }); + it("rejects unknown severity labels", () => { expect(() => buildPackageVulnerabilitiesParams({ diff --git a/src/shared/package-vulnerabilities-request.ts b/src/shared/package-vulnerabilities-request.ts index f32c2da2..e14a5259 100644 --- a/src/shared/package-vulnerabilities-request.ts +++ b/src/shared/package-vulnerabilities-request.ts @@ -102,8 +102,14 @@ export interface PackageVulnerabilitiesRequestInput { includeWithdrawn?: boolean; } +export interface PackageVulnerabilitiesFilterEcho { + minSeverity?: SeverityLabel; + includeWithdrawn?: true; +} + export interface PackageVulnerabilitiesRequestBuildResult { params: PackageVulnerabilitiesParams; + filter?: PackageVulnerabilitiesFilterEcho; } export function buildPackageVulnerabilitiesParams( @@ -130,7 +136,11 @@ export function buildPackageVulnerabilitiesParams( ); } - const minSeverity = resolveMinSeverity(input.minSeverity); + const severityLabel = resolveMinSeverityLabel(input.minSeverity); + const minSeverity = + severityLabel !== undefined + ? SEVERITY_LABEL_TO_CVSS[severityLabel] + : undefined; const trimmedVersion = input.version?.trim(); // Temporary guard until the backend returns a typed invalid-version // error with per-ecosystem version semantics. @@ -140,6 +150,8 @@ export function buildPackageVulnerabilitiesParams( ); } + const filter = buildFilterEcho(severityLabel, input.includeWithdrawn); + return { params: { registry, @@ -151,10 +163,13 @@ export function buildPackageVulnerabilitiesParams( minSeverity, includeWithdrawn: input.includeWithdrawn, }, + ...(filter ? { filter } : {}), }; } -function resolveMinSeverity(raw: string | undefined): number | undefined { +function resolveMinSeverityLabel( + raw: string | undefined, +): SeverityLabel | undefined { if (raw === undefined) return undefined; const trimmed = raw.trim(); if (trimmed.length === 0) return undefined; @@ -164,7 +179,17 @@ function resolveMinSeverity(raw: string | undefined): number | undefined { `Unsupported severity '${raw}'. Expected one of: low, medium, high, critical.`, ); } - return SEVERITY_LABEL_TO_CVSS[lower]; + return lower; +} + +function buildFilterEcho( + minSeverity: SeverityLabel | undefined, + includeWithdrawn: boolean | undefined, +): PackageVulnerabilitiesFilterEcho | undefined { + const filter: PackageVulnerabilitiesFilterEcho = {}; + if (minSeverity !== undefined) filter.minSeverity = minSeverity; + if (includeWithdrawn === true) filter.includeWithdrawn = true; + return Object.keys(filter).length > 0 ? filter : undefined; } export function isSeverityLabel(value: string): value is SeverityLabel { diff --git a/src/shared/package-vulnerabilities-response.test.ts b/src/shared/package-vulnerabilities-response.test.ts index 52ee9156..c0c9e32b 100644 --- a/src/shared/package-vulnerabilities-response.test.ts +++ b/src/shared/package-vulnerabilities-response.test.ts @@ -5,6 +5,7 @@ import { buildPackageVulnerabilitiesSuccessPayload, compareVersionsAscending, computeBySeverity, + DEFAULT_ADVISORY_CAP, dedupAdvisoriesByAlias, formatPackageVulnerabilitiesTerminal, vulnSeverityLabel, @@ -300,6 +301,25 @@ describe("buildPackageVulnerabilitiesSuccessPayload — requestedVersion echo", expect(payload.requestedVersion).toBe("4.17"); }); + it("echoes explicit filters additively in the JSON payload", () => { + const payload = buildPackageVulnerabilitiesSuccessPayload( + defaultVulnerabilityReport, + { filter: { minSeverity: "high", includeWithdrawn: true } }, + ); + expect(payload.filter).toEqual({ + minSeverity: "high", + includeWithdrawn: true, + }); + expect(payload.advisories?.[0]?.id).toBe("GHSA-mmmm-mmmm-mmmm"); + }); + + it("omits filter from JSON when caller supplied none", () => { + const payload = buildPackageVulnerabilitiesSuccessPayload( + defaultVulnerabilityReport, + ); + expect(payload.filter).toBeUndefined(); + }); + it("partition invariant: bucket sum equals vulnerabilities.length (and summary.total when backend is consistent)", () => { // Client-side guarantee: the six buckets partition // `security.vulnerabilities[]`. In the default fixture the @@ -720,7 +740,7 @@ describe("formatPackageVulnerabilitiesTerminal", () => { useColors: false, }); expect(output).toBe( - "clean @ 1.0.0 · npm\nNo known vulnerabilities affect this version.\n", + "clean @ 1.0.0 | npm\nNo active vulnerabilities affect this version.\n", ); }); @@ -734,7 +754,7 @@ describe("formatPackageVulnerabilitiesTerminal", () => { useColors: false, }); expect(output).toBe( - "clean @ 1.0.0 · npm\nNo vulnerabilities affect this version (2 historical advisories do not apply).\n", + "clean @ 1.0.0 | npm\nNo active vulnerabilities affect this version (2 historical advisories do not apply).\n", ); }); @@ -748,23 +768,33 @@ describe("formatPackageVulnerabilitiesTerminal", () => { useColors: false, }); expect(output).toBe( - "clean @ 1.0.0 · npm\nNo vulnerabilities affect this version (1 historical advisory does not apply).\n", + "clean @ 1.0.0 | npm\nNo active vulnerabilities affect this version (1 historical advisory does not apply).\n", + ); + }); + + it("renders filtered zero-vulns with filter context", () => { + const output = formatPackageVulnerabilitiesTerminal(zeroVulnsFixture(), { + useColors: false, + filter: { minSeverity: "high" }, + }); + expect(output).toBe( + "clean @ 1.0.0 | npm\nFilter severity >= high\nNo vulnerabilities matching the filter affect this version.\n", ); }); it("renders default terminal block with header, summary, breakdown, advisories, footer", () => { const output = formatPackageVulnerabilitiesTerminal( defaultVulnerabilityReport, - { useColors: false }, + { useColors: false, verbose: true }, ); - expect(output).toContain("express @ 4.18.0 · npm"); + expect(output).toContain("express @ 4.18.0 | npm"); expect(output).toContain("6 vulnerabilities affect this version"); expect(output).toContain("MALWARE"); expect(output).toContain("GHSA-mmmm-mmmm-mmmm"); expect(output).toContain("Fix version: 4.18.2."); }); - it("shows MALWARE · crit combined label for malicious + severe advisory", () => { + it("shows MALWARE | crit combined label for malicious + severe advisory", () => { const fixture = cloneFixture(); if (fixture.security?.vulnerabilities?.[0]) { fixture.security.vulnerabilities[0].severityScore = 9.5; @@ -772,7 +802,7 @@ describe("formatPackageVulnerabilitiesTerminal", () => { const output = formatPackageVulnerabilitiesTerminal(fixture, { useColors: false, }); - expect(output).toContain("MALWARE · critical"); + expect(output).toContain("MALWARE | critical"); }); it("omits breakdown line when total is 1", () => { @@ -851,12 +881,12 @@ describe("formatPackageVulnerabilitiesTerminal", () => { it("breakdown line includes unrated bucket so the sum reconciles with total", () => { const output = formatPackageVulnerabilitiesTerminal( defaultVulnerabilityReport, - { useColors: false }, + { useColors: false, verbose: true }, ); // Fixture has 6 advisories: 1 malware, 1 crit, 1 high, 1 medium, 1 low, // 1 unrated. Breakdown line must enumerate all six to match the total. expect(output).toMatch( - /1 MALWARE · 1 crit · 1 high · 1 medium · 1 low · 1 unrated/, + /1 MALWARE \| 1 crit \| 1 high \| 1 medium \| 1 low \| 1 unrated/, ); }); @@ -879,7 +909,7 @@ describe("formatPackageVulnerabilitiesTerminal", () => { }); // First 4 ranges shown; the remaining 3 collapse into the hint. expect(output).toContain( - "affected ==1.0.0, ==1.0.1, ==1.0.2, ==1.0.3, … (+3 more; use -v)", + "affected ==1.0.0, ==1.0.1, ==1.0.2, ==1.0.3, ... (+3 more; use -v)", ); expect(output).not.toContain("==1.0.4,"); }); @@ -896,7 +926,7 @@ describe("formatPackageVulnerabilitiesTerminal", () => { useColors: false, }); expect(output).toContain( - "affected ==1.0.0, ==1.0.1, … (+3 ranges omitted by service)", + "affected ==1.0.0, ==1.0.1, ... (+3 ranges omitted by service)", ); }); @@ -921,6 +951,58 @@ describe("formatPackageVulnerabilitiesTerminal", () => { expect(output).not.toContain("+2 more"); }); + it("caps default advisory rows and bases hidden count on rendered advisories", () => { + const fixture = cloneFixture(); + if (fixture.security) { + fixture.security.affectedVulnerabilityCount = 99; + } + const output = formatPackageVulnerabilitiesTerminal(fixture, { + useColors: false, + }); + expect(DEFAULT_ADVISORY_CAP).toBe(5); + expect(output).toContain("... (+1 more; use -v)"); + expect(output).not.toContain("... (+94 more"); + expect(output).not.toContain("GHSA-nnnn-nnnn-nnnn"); + }); + + it("verbose mode shows all advisory rows", () => { + const output = formatPackageVulnerabilitiesTerminal( + defaultVulnerabilityReport, + { useColors: false, verbose: true }, + ); + expect(output).not.toContain("... (+1 more; use -v)"); + expect(output).toContain("GHSA-nnnn-nnnn-nnnn"); + }); + + it("uses MCP-native truncation hints", () => { + const output = formatPackageVulnerabilitiesTerminal( + defaultVulnerabilityReport, + { useColors: false, surface: "mcp" }, + ); + expect(output).toContain("... (+1 more; use verbose=true or format=json)"); + expect(output).not.toContain("use -v"); + }); + + it("echoes filters in the terminal header", () => { + const output = formatPackageVulnerabilitiesTerminal( + defaultVulnerabilityReport, + { + useColors: false, + filter: { minSeverity: "high", includeWithdrawn: true }, + }, + ); + expect(output).toContain("Filter severity >= high"); + expect(output).toContain("Filter include withdrawn"); + }); + + it("pkg_vulns text output is printable ASCII", () => { + const output = formatPackageVulnerabilitiesTerminal( + defaultVulnerabilityReport, + { useColors: false, verbose: true }, + ); + expect(output).not.toMatch(/[·…—–]/); + }); + it("singular vulnerability noun when total is 1", () => { const fixture = cloneFixture(); if (fixture.security) { @@ -1024,7 +1106,7 @@ describe("unrated severity column", () => { it("fills the severity gutter for null-CVSS non-malicious advisories", () => { const output = formatPackageVulnerabilitiesTerminal( defaultVulnerabilityReport, - { useColors: false }, + { useColors: false, verbose: true }, ); // The fixture's null-severity advisory (GHSA-nnnn) used to render // with a blank severity column. It now reads "unrated", matching @@ -1051,7 +1133,7 @@ describe("affected-range cap adapts to terminal width", () => { buildManyRangesFixture(10), { useColors: false, terminalWidth: 80 }, ); - expect(output).toContain("==1.0.0, ==1.1.0, ==1.2.0, ==1.3.0, …"); + expect(output).toContain("==1.0.0, ==1.1.0, ==1.2.0, ==1.3.0, ..."); expect(output).toContain("(+6 more; use -v)"); expect(output).not.toContain("==1.4.0"); }); diff --git a/src/shared/package-vulnerabilities-response.ts b/src/shared/package-vulnerabilities-response.ts index 678e2739..82203279 100644 --- a/src/shared/package-vulnerabilities-response.ts +++ b/src/shared/package-vulnerabilities-response.ts @@ -1,7 +1,7 @@ /** * Hand-crafted response envelope for the `package_vulnerabilities` - * tool. Shared by CLI `--json` output and MCP `content[0].text`. The - * terminal formatter is CLI-only. + * tool. Shared by CLI `--json`, CLI terminal output, and MCP + * `content[0].text`. * * Key design commitments (locked in the plan): * - Backend is the single source of truth for counts. `minSeverity` @@ -47,6 +47,7 @@ import type { } from "../services/index.js"; import { colorize, dim } from "./colors.js"; import { toIsoDate } from "./format-date.js"; +import type { PackageVulnerabilitiesFilterEcho } from "./package-vulnerabilities-request.js"; import { toPkgseerRegistryLowercase } from "./pkgseer-registry.js"; export type VulnSeverityLabel = "critical" | "high" | "medium" | "low"; @@ -93,6 +94,7 @@ export interface LeanVulnerabilityReport { version: string; requestedVersion?: string; summary: LeanVulnerabilitySummary; + filter?: PackageVulnerabilitiesFilterEcho; advisories?: LeanAdvisory[]; upgradePaths?: string[]; } @@ -100,8 +102,12 @@ export interface LeanVulnerabilityReport { export interface BuildVulnerabilitiesPayloadOptions { /** Raw caller-supplied version string (pre-normalisation). */ requestedVersion?: string; + /** Caller-supplied filters, echoed from shared request parsing. */ + filter?: PackageVulnerabilitiesFilterEcho; } +export const DEFAULT_ADVISORY_CAP = 5; + /** * Build the lean envelope from a validated {@link VulnerabilityReport}. * Pure, deterministic — no clock, no env reads. @@ -139,6 +145,10 @@ export function buildPackageVulnerabilitiesSuccessPayload( payload.requestedVersion = requestedEcho; } + if (options.filter !== undefined) { + payload.filter = options.filter; + } + if (total > 0) { const sortedAdvisories = sortAdvisories( dedupedAdvisories.map(buildAdvisory), @@ -740,13 +750,17 @@ function lowerRegistry(value: string | undefined): string { } // -------------------------------------------------------------------- -// Terminal formatter (CLI-only; MCP never invokes this) +// Shared terminal/text formatter used by both CLI and MCP. // -------------------------------------------------------------------- +export type VulnerabilitiesTextSurface = "cli" | "mcp"; + export interface FormatVulnerabilitiesTerminalOptions { verbose?: boolean; useColors?: boolean; requestedVersion?: string; + filter?: PackageVulnerabilitiesFilterEcho; + surface?: VulnerabilitiesTextSurface; /** * Terminal width in columns. Used to decide how many * affected-version ranges to show before collapsing into a @@ -762,18 +776,22 @@ export function formatPackageVulnerabilitiesTerminal( ): string { const payload = buildPackageVulnerabilitiesSuccessPayload(report, { requestedVersion: options.requestedVersion, + filter: options.filter, }); const useColors = options.useColors ?? false; const verbose = options.verbose ?? false; + const surface = options.surface ?? "cli"; const headerLine = formatHeader(payload, useColors); const requestedLine = payload.requestedVersion ? dim(`(requested ${payload.requestedVersion})`, useColors) : undefined; + const filterLines = formatFilterLines(payload.filter); if (payload.summary.total === 0) { const lines = [headerLine]; if (requestedLine) lines.push(requestedLine); + lines.push(...filterLines); lines.push(formatNoAffectedVulnerabilitiesLine(payload)); return `${lines.join("\n")}\n`; } @@ -781,6 +799,7 @@ export function formatPackageVulnerabilitiesTerminal( const blocks: string[] = []; const headerBlock: string[] = [headerLine]; if (requestedLine) headerBlock.push(requestedLine); + headerBlock.push(...filterLines); headerBlock.push(formatSummaryLine(payload, useColors)); const breakdown = formatBreakdownLine(payload.summary, useColors); if (breakdown) headerBlock.push(breakdown); @@ -789,7 +808,13 @@ export function formatPackageVulnerabilitiesTerminal( if (payload.advisories && payload.advisories.length > 0) { const rangeLimit = resolveAffectedRangesLimit(options.terminalWidth); blocks.push( - formatAdvisoryList(payload.advisories, verbose, useColors, rangeLimit), + formatAdvisoryList( + payload.advisories, + verbose, + useColors, + rangeLimit, + surface, + ), ); } @@ -804,7 +829,21 @@ function formatHeader( useColors: boolean, ): string { const name = colorize(payload.name, "bold", useColors); - return `${name} @ ${payload.version} · ${payload.registry}`; + return `${name} @ ${payload.version} | ${payload.registry}`; +} + +function formatFilterLines( + filter: PackageVulnerabilitiesFilterEcho | undefined, +): string[] { + if (!filter) return []; + const lines: string[] = []; + if (filter.minSeverity) { + lines.push(`Filter severity >= ${filter.minSeverity}`); + } + if (filter.includeWithdrawn === true) { + lines.push("Filter include withdrawn"); + } + return lines; } function formatSummaryLine( @@ -829,14 +868,17 @@ function formatSummaryLine( function formatNoAffectedVulnerabilitiesLine( payload: LeanVulnerabilityReport, ): string { + if (payload.filter !== undefined) { + return "No vulnerabilities matching the filter affect this version."; + } const historical = payload.summary.nonAffectingVulnerabilityCount ?? 0; if (historical > 0) { const noun = historical === 1 ? "historical advisory" : "historical advisories"; const verb = historical === 1 ? "does" : "do"; - return `No vulnerabilities affect this version (${historical} ${noun} ${verb} not apply).`; + return `No active vulnerabilities affect this version (${historical} ${noun} ${verb} not apply).`; } - return "No known vulnerabilities affect this version."; + return "No active vulnerabilities affect this version."; } function formatBreakdownLine( @@ -866,7 +908,7 @@ function formatBreakdownLine( } } if (parts.length === 0) return undefined; - return ` ${parts.join(" · ")}`; + return ` ${parts.join(" | ")}`; } // -------------------------------------------------------------------- @@ -878,12 +920,16 @@ function formatAdvisoryList( verbose: boolean, useColors: boolean, rangeLimit: number, + surface: VulnerabilitiesTextSurface, ): string { + const renderedAdvisories = verbose + ? advisories + : advisories.slice(0, DEFAULT_ADVISORY_CAP); const labelWidth = Math.max( - ...advisories.map((a) => severityColumnLabel(a).length), + ...renderedAdvisories.map((a) => severityColumnLabel(a).length), ); const lines: string[] = []; - for (const advisory of advisories) { + for (const advisory of renderedAdvisories) { lines.push( ...formatAdvisoryLines( advisory, @@ -891,16 +937,29 @@ function formatAdvisoryList( verbose, useColors, rangeLimit, + surface, ), ); lines.push(""); } + const hidden = advisories.length - renderedAdvisories.length; + if (hidden > 0) { + lines.push(dim(formatAdvisoryCapHint(hidden, surface), useColors)); + } return lines.join("\n").trimEnd(); } +function formatAdvisoryCapHint( + hidden: number, + surface: VulnerabilitiesTextSurface, +): string { + const hint = surface === "mcp" ? "use verbose=true or format=json" : "use -v"; + return `... (+${hidden} more; ${hint})`; +} + function severityColumnLabel(advisory: LeanAdvisory): string { if (advisory.isMalicious === true) { - if (advisory.severityLabel) return `MALWARE · ${advisory.severityLabel}`; + if (advisory.severityLabel) return `MALWARE | ${advisory.severityLabel}`; return "MALWARE"; } // Filling the column with "unrated" (dim) when the advisory has no @@ -955,6 +1014,7 @@ function formatAdvisoryLines( verbose: boolean, useColors: boolean, rangeLimit: number, + surface: VulnerabilitiesTextSurface, ): string[] { const rawLabel = severityColumnLabel(advisory); const padded = rawLabel.padEnd(labelWidth); @@ -981,6 +1041,7 @@ function formatAdvisoryLines( verbose, useColors, rangeLimit, + surface, advisory.affectedVersionRangesCount, advisory.affectedVersionRangesTruncated, ), @@ -1026,6 +1087,7 @@ function formatRangeList( verbose: boolean, useColors: boolean, limit: number, + surface: VulnerabilitiesTextSurface, totalCount: number | undefined, backendTruncated: boolean | undefined, ): string { @@ -1035,7 +1097,7 @@ function formatRangeList( const appendBackendHint = (shown: string): string => { if (backendHidden > 0) { const hint = dim( - `… (+${backendHidden} ranges omitted by service)`, + `... (+${backendHidden} ranges omitted by service)`, useColors, ); return shown.length > 0 ? `${shown}, ${hint}` : hint; @@ -1048,10 +1110,11 @@ function formatRangeList( } const shown = ranges.slice(0, limit).join(", "); const localHidden = ranges.length - limit; + const localHint = surface === "mcp" ? "use verbose=true" : "use -v"; const hintText = backendHidden > 0 - ? `… (+${localHidden} more with -v; +${backendHidden} omitted by service)` - : `… (+${localHidden} more; use -v)`; + ? `... (+${localHidden} more with ${localHint}; +${backendHidden} omitted by service)` + : `... (+${localHidden} more; ${localHint})`; const hint = dim(hintText, useColors); return `${shown}, ${hint}`; } diff --git a/src/tools/package-vulnerabilities-parity.test.ts b/src/tools/package-vulnerabilities-parity.test.ts index 5c86fb32..b979ec8c 100644 --- a/src/tools/package-vulnerabilities-parity.test.ts +++ b/src/tools/package-vulnerabilities-parity.test.ts @@ -120,6 +120,75 @@ describe("package_vulnerabilities parity", () => { expect(cli).toEqual(json); }); + it("PARITY-JSON-KEYS: include_withdrawn filter echo CLI === MCP", async () => { + const fn = mock(() => Promise.resolve(defaultVulnerabilityReport)); + const cli = await cliJson( + "npm:express", + { includeWithdrawn: true }, + cliDeps({ + packageIntelligenceService: createMockPackageIntelligenceService({ + packageVulnerabilities: fn as never, + }), + }), + ); + const { json } = await mcpJson( + { registry: "npm", package_name: "express", include_withdrawn: true }, + fn as never, + ); + expect(cli).toEqual(json); + expect((cli as { filter?: unknown }).filter).toEqual({ + includeWithdrawn: true, + }); + }); + + it("PARITY-JSON-KEYS: combined filter echo CLI === MCP", async () => { + const fn = mock(() => Promise.resolve(defaultVulnerabilityReport)); + const cli = await cliJson( + "npm:express", + { severity: "HIGH", includeWithdrawn: true }, + cliDeps({ + packageIntelligenceService: createMockPackageIntelligenceService({ + packageVulnerabilities: fn as never, + }), + }), + ); + const { json } = await mcpJson( + { + registry: "npm", + package_name: "express", + min_severity: "HIGH", + include_withdrawn: true, + }, + fn as never, + ); + expect(cli).toEqual(json); + expect((cli as { filter?: unknown }).filter).toEqual({ + minSeverity: "high", + includeWithdrawn: true, + }); + }); + + it("PARITY-JSON-KEYS: filtered zero response CLI === MCP", async () => { + const fn = mock(() => Promise.resolve(zeroVulnsReport())); + const cli = await cliJson( + "npm:clean", + { severity: "high" }, + cliDeps({ + packageIntelligenceService: createMockPackageIntelligenceService({ + packageVulnerabilities: fn as never, + }), + }), + ); + const { json } = await mcpJson( + { registry: "npm", package_name: "clean", min_severity: "high" }, + fn as never, + ); + expect(cli).toEqual(json); + expect((cli as { filter?: unknown }).filter).toEqual({ + minSeverity: "high", + }); + }); + it("PARITY-JSON-KEYS: zero-vulns hot path CLI === MCP", async () => { const zeroFn = mock(() => Promise.resolve(zeroVulnsReport())); const cli = await cliJson( diff --git a/src/tools/package-vulnerabilities.test.ts b/src/tools/package-vulnerabilities.test.ts index 49a014c8..e2f4a2c2 100644 --- a/src/tools/package-vulnerabilities.test.ts +++ b/src/tools/package-vulnerabilities.test.ts @@ -25,6 +25,7 @@ describe("createPackageVulnerabilitiesTool — metadata", () => { "min_severity", "package_name", "registry", + "verbose", "version", ]); expect(tool.annotations?.readOnlyHint).toBe(true); @@ -80,11 +81,38 @@ describe("createPackageVulnerabilitiesTool — happy path", () => { ); expect(result.isError).toBeUndefined(); const text = result.content[0]?.text ?? ""; - expect(text).toContain("express @ 4.18.0 · npm"); + expect(text).toContain("express @ 4.18.0 | npm"); expect(text).toContain("vulnerabilities affect this version"); expect(() => JSON.parse(text)).toThrow(); }); + it("returns complete text when verbose=true", async () => { + const tool = createPackageVulnerabilitiesTool( + createMockPackageIntelligenceService(), + ); + const result = await tool.handler( + { registry: "npm", package_name: "express", verbose: true }, + {}, + ); + const text = result.content[0]?.text ?? ""; + expect(text).toContain("GHSA-nnnn-nnnn-nnnn"); + expect(text).not.toContain("use -v"); + expect(text).not.toContain("... (+1 more"); + }); + + it("uses MCP-native cap hint in default text", async () => { + const tool = createPackageVulnerabilitiesTool( + createMockPackageIntelligenceService(), + ); + const result = await tool.handler( + { registry: "npm", package_name: "express" }, + {}, + ); + const text = result.content[0]?.text ?? ""; + expect(text).toContain("... (+1 more; use verbose=true or format=json)"); + expect(text).not.toContain("use -v"); + }); + it("returns JSON-stringified lean envelope when format=json", async () => { const tool = createPackageVulnerabilitiesTool( createMockPackageIntelligenceService(), @@ -100,6 +128,47 @@ describe("createPackageVulnerabilitiesTool — happy path", () => { expect((payload.summary as { total: number }).total).toBe(6); }); + it("echoes explicit filters in JSON", async () => { + const tool = createPackageVulnerabilitiesTool( + createMockPackageIntelligenceService(), + ); + const result = await tool.handler( + { + registry: "npm", + package_name: "express", + min_severity: "HIGH", + include_withdrawn: true, + format: "json", + }, + {}, + ); + const payload = parseText(result) as { filter?: unknown }; + expect(payload.filter).toEqual({ + minSeverity: "high", + includeWithdrawn: true, + }); + }); + + it("ignores verbose for JSON output shape", async () => { + const tool = createPackageVulnerabilitiesTool( + createMockPackageIntelligenceService(), + ); + const normal = await tool.handler( + { registry: "npm", package_name: "express", format: "json" }, + {}, + ); + const verbose = await tool.handler( + { + registry: "npm", + package_name: "express", + format: "json", + verbose: true, + }, + {}, + ); + expect(parseText(verbose)).toEqual(parseText(normal)); + }); + it("surfaces requestedVersion when caller passes a real-diff version", async () => { const tool = createPackageVulnerabilitiesTool( createMockPackageIntelligenceService(), diff --git a/src/tools/package-vulnerabilities.ts b/src/tools/package-vulnerabilities.ts index c1f17d54..83afde10 100644 --- a/src/tools/package-vulnerabilities.ts +++ b/src/tools/package-vulnerabilities.ts @@ -14,6 +14,7 @@ export interface PackageVulnerabilitiesArgs { version?: string; min_severity?: string; include_withdrawn?: boolean; + verbose?: boolean; format?: "json" | "text" | "text-v1"; } @@ -46,6 +47,12 @@ const schema = { .boolean() .optional() .describe("Include retracted advisories (default: false)."), + verbose: z + .boolean() + .optional() + .describe( + "Text output only. Show every advisory and full detail rows; format=json always returns the complete structured envelope.", + ), format: z .enum(["json", "text", "text-v1"]) .optional() @@ -59,8 +66,12 @@ const DESCRIPTION = "Crates (other registries are not yet supported for vulnerability " + "data). Returns a count summary, each advisory with OSV ID, " + "severity, affected ranges, and fix versions. Malicious-package " + - "advisories surface in a separate bucket. Pass `version` to inspect " + - "a specific release; otherwise the latest is checked. Use " + + "advisories surface in a separate bucket. Example: " + + '`{"registry":"npm","package_name":"lodash","version":"4.17.20","min_severity":"high"}`. ' + + "Pass `version` to inspect " + + "a pinned release; omit it for latest. Default text is capped for " + + "readability; use `verbose:true` for all advisory rows or " + + '`format:"json"` for the complete envelope. Use ' + "`min_severity` to filter to a threshold (`low`, `medium`, `high`, " + "`critical`) and `include_withdrawn` to also see retracted " + "advisories."; @@ -75,7 +86,7 @@ export function createPackageVulnerabilitiesTool( annotations: { readOnlyHint: true }, handler: async (args) => { try { - const { params } = buildPackageVulnerabilitiesParams({ + const { params, filter } = buildPackageVulnerabilitiesParams({ registry: args.registry, packageName: args.package_name, version: args.version, @@ -85,12 +96,16 @@ export function createPackageVulnerabilitiesTool( const report = await service.packageVulnerabilities(params); const payload = buildPackageVulnerabilitiesSuccessPayload(report, { requestedVersion: args.version, + filter, }); if (isTextFormat(args.format)) { return textResult( formatPackageVulnerabilitiesTerminal(report, { useColors: false, requestedVersion: args.version, + filter, + verbose: args.verbose, + surface: "mcp", }).trimEnd(), ); }