Add SkillGuard risky skills card to the AI Governance dashboard
What does this MR do and why?
Adds a Most risky skills card to the AI Governance dashboard at group and project scope, behind ai_governance_skillguard_widget (wip, off). Implements https://gitlab.com/gitlab-org/gitlab/-/work_items/629070.
No new backend. SkillGuard publishes into the Vulnerability Report as a SAST report with scanner.external_id = "skillguard", so the card reads two fields that already exist on both Group and Project:
vulnerabilitySeveritiesCount(scanner: ["skillguard"], state: [DETECTED, CONFIRMED]) { critical high medium low unknown info }
vulnerabilities(scanner: ["skillguard"], state: [DETECTED, CONFIRMED], sort: severity_desc, first: 5) {
nodes { id title severity vulnerabilityPath project { id name } }
}Both resolve through Security::VulnerabilityReadsFinder on Postgres. scanner is not an advanced filter, so no Elasticsearch or advanced vulnerability management is required, and vulnerability_reads.scanner_id is indexed.
Verdict labels
Counts render as verdicts using the mapping agreed on the issue: critical → malicious, high → suspicious (or dangerous). This is a product convention rather than data we can read back: the analyzer emits verdict and severity as independent LLM fields and publishes only severity.
Anything outside critical and high falls back to its own severity name rather than being guessed at, because the analyzer's docs/verdict-system.md maps RISKY to Low/Medium and DANGEROUS to Medium/High, and only SAFE is suppressed. There is a spec for that fallback.
Deep-link contract
The two scopes need different URLs.
| Scope | URL |
|---|---|
| Project | /<ns>/<project>/-/security/vulnerability_report?scanner=skillguard |
| Group | /groups/<group>/-/security/vulnerabilities?identifier=SkillGuard+Skill+Analysis |
FILTER_PRESETS.DEVELOPMENT_PROJECT includes FILTERS.SCANNER, but FILTER_PRESETS.DEVELOPMENT (group) does not, so ?scanner= is silently dropped at group scope. Both presets include FILTERS.IDENTIFIER, and identifier_name is supported on Postgres, so the group link filters by identifier name instead. Both verified to land with the filter token applied.
Sorting
sort: severity_desc, which is deterministic in both scopes: severity DESC, vulnerability_id DESC at project scope and severity DESC, traversal_ids DESC, vulnerability_id DESC at group scope. Most severe first, then most recent, which is what the card title promises.
Filters this card does not follow
The dashboard's Show (agent class) and Date range controls do not apply here, deliberately:
- A vulnerability carries no agent dimension. Linking a flagged skill to the agents that consume it is exposure mapping, deferred to GA by the parent epic.
- The card shows findings that are open now, not findings detected within a window, so a date filter would hide an unresolved malicious skill found outside it. Neither vulnerability field exposes a date argument in any case.
Same situation as the AI agent inventory and Audit logs cards, tracked under #610984.
Out of scope for beta
"Skills scanned" and the per-project scan-coverage footer were dropped from the design, because safe skills produce no vulnerability and therefore leave no scanned total or per-project scan record.
Screenshots
Captured on GDK with 21 seeded findings (4 critical, 17 high) across 4 projects.
| Group scope |
|---|
![]() |
In place on the dashboard:
At project scope the same card narrows to that project's findings.
Rollout
The flag stays off. SkillGuard is not yet registered in the monolith, so on production the card renders its empty state. The remaining path is analyzers/skillguard#9, which depends on the SkillGuard DAP flow landing in ai-assist.
Steps to verify this locally
Nothing in GitLab produces SkillGuard findings yet, so the card renders its empty state on a
fresh GDK. To see it with data, seed findings that look like what the analyzer publishes:
report_type: sast, scanner.external_id: "skillguard", and a SkillGuard Skill Analysis
primary identifier.
1. Enable the flag and seed findings
# bin/rails runner -
group = Group.find_by_full_path('your-group') # a root group you can open
Feature.enable(:ai_governance_skillguard_widget, group)
author = User.find_by(username: 'root')
group.all_projects.order(:id).first(4).each_with_index do |project, i|
# A vulnerability on a project with no default branch 500s the group report:
# Finding#sha falls back to the branch name, and blob_path does File.join(nil, ...).
if project.default_branch.blank?
project.repository.create_if_not_exists
project.repository.create_file(author, 'README.md', "# seed\n",
message: 'Initial commit', branch_name: 'main')
project.change_head('main')
end
scanner = Vulnerabilities::Scanner.find_or_create_by!(
project: project, external_id: 'skillguard') { |s| s.name = 'SkillGuard'; s.vendor = 'GitLab' }
[%w[0x-swap critical], %w[secrets-sync critical],
%w[deploy-to-prod high], %w[log-shipper high], %w[cache-warmer high]].each do |name, severity|
identifier = FactoryBot.create(:vulnerabilities_identifier, project: project,
external_type: 'skillguard', external_id: 'skillguard-skill-analysis',
fingerprint: SecureRandom.hex(20), name: 'SkillGuard Skill Analysis', url: nil)
finding = FactoryBot.create(:vulnerabilities_finding, project: project, scanner: scanner,
primary_identifier: identifier, project_tracked_context: nil, report_type: :sast,
severity: severity.to_sym, name: name, location_fingerprint: SecureRandom.hex(20),
location: { 'file' => "skills/#{name}/SKILL.md", 'start_line' => 1 },
raw_metadata: { location: { file: "skills/#{name}/SKILL.md", start_line: 1 } }.to_json)
finding.identifiers << identifier
vulnerability = FactoryBot.create(:vulnerability, :detected, project: project, author: author,
title: name, severity: severity.to_sym, report_type: :sast,
present_on_default_branch: true, vulnerability_finding: finding)
# vulnerability_reads is normally written by a Postgres trigger whose INSERT omits
# traversal_ids, which makes the row invisible to every group-scoped query. Suppress
# the trigger so the upsert service builds the row with traversal_ids set.
SecApplicationRecord.transaction do
SecApplicationRecord.connection.execute(
"SELECT set_config('vulnerability_management.dont_execute_db_trigger', 'true', true)")
finding.update!(vulnerability_id: vulnerability.id)
Vulnerabilities::Reads::UpsertService.new([vulnerability.id], {}, projects: [project],
update_all_vulnerabilities: true, findings: [finding]).execute
end
end
end
puts Security::VulnerabilityReadsFinder.new(group, { scanner: ['skillguard'] })
.execute.count_by_severity.inspect2. Open the dashboard at Settings → GitLab Duo → Governance → Dashboard, group scope
(/groups/<group>/-/settings/gitlab_duo/governance?tab=dashboard) and the same page on a
project. The card should show 8 malicious · 12 suspicious, 20 flagged, and the five most
severe skills.
3. Check the counts agree with the API. The numbers on the card come straight from
count_by_severity, so the seed script's final line should match what the card shows.
4. Click "View all skills". It should land on a pre-filtered Vulnerability Report:
Scanner = SkillGuard at project scope, Identifier = SkillGuard Skill Analysis at group
scope. The Report type column reads "SAST / SkillGuard".
5. Empty and error states. Disable the flag to confirm the card disappears; delete the seeded vulnerabilities to see the empty state.
MR acceptance checklist
- Frontend specs pass (218 in the governance suite)
- Behind a feature flag, default off
- Usage tracking on the CTA (out of scope here, being added dashboard-wide)
- Verified with real data on GDK
- Design review (#627762)
- User documentation

